diff --git a/.editorconfig b/.editorconfig index a378753..b45e3c9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,7 +48,7 @@ indent_size = unset # changes, each fenced `// ZEPHCORE:`. Upstream mixes 2-space and tab indentation # and has trailing whitespace, so none of it is enforced here. Add a file to this # list when it is converted to upstream shape. -[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h}] +[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h,zephcore/helpers/BaseChatMesh.cpp,zephcore/helpers/BaseChatMesh.h,zephcore/helpers/ContactInfo.h,zephcore/helpers/ChannelDetails.h}] indent_style = unset indent_size = unset tab_width = unset diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 308c86d..bbdcb14 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1175,14 +1175,14 @@ DIO1 interrupt → Zephyr lora driver → async RX callback → direct: process immediately → Mesh::onRecvPacket() → decrypt → dispatch by type → BaseChatMesh::onPeerDataRecv() → onMessageRecv() - → CompanionMesh: writeFrame() to phone or queueOfflineMessage() + → CompanionMesh: writeFrame() to phone or addToOfflineQueue() ``` ### 14.2 Sending a Text Message ``` Phone sends CMD_SEND_TXT_MSG via BLE NUS - → CompanionMesh::handleProtocolFrame() + → CompanionMesh::handleCmdFrame() → handleCmd() → BaseChatMesh::sendMessage(contact, text) → composeMsgPacket(): ECDH secret → AES encrypt → MAC → if contact has path: trySendDirect() diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index f95c258..2d37126 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -577,6 +577,7 @@ else() helpers/TransportKeyStore.cpp helpers/ui/ui_mesh_actions.cpp app/CompanionMesh.cpp + app/VContact.cpp # CommonCLI backs the v-contact loopback admin chat (companion_cli_exec in # main_companion.cpp), which is NOT gated on the wired-USB stack — so it # must be compiled unconditionally. Builds without CONFIG_LOG/COMPANION_USB/ diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp index baebb2f..a98f636 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp @@ -94,7 +94,7 @@ static struct k_spinlock usb_tx_lock; /* Main-thread wake for assembled binary frames (set by init). The byte * assembly below runs on sysworkq, but V3-protocol parsing must happen on the - * main thread (handleProtocolFrame mutates mesh state shared with loop()), so + * main thread (handleCmdFrame mutates mesh state shared with loop()), so * we post this event instead of running the parser here. */ static struct k_event *s_mesh_events; static uint32_t s_mesh_event_ble_rx; diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 4499ad0..2d74e5b 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -26,117 +26,7 @@ #include LOG_MODULE_REGISTER(zephcore_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); -/* Protocol commands (matches Arduino companion_radio) - sorted by opcode */ -#define CMD_APP_START 0x01 -#define CMD_SEND_TXT_MSG 0x02 -#define CMD_SEND_CHANNEL_TXT_MSG 0x03 -#define CMD_GET_CONTACTS 0x04 -#define CMD_GET_DEVICE_TIME 0x05 -#define CMD_SET_DEVICE_TIME 0x06 -#define CMD_SEND_SELF_ADVERT 0x07 -#define CMD_SET_ADVERT_NAME 0x08 -#define CMD_ADD_UPDATE_CONTACT 0x09 -#define CMD_SYNC_NEXT_MESSAGE 0x0A -#define CMD_SET_RADIO_PARAMS 0x0B -#define CMD_SET_RADIO_TX_POWER 0x0C -#define CMD_RESET_PATH 0x0D -#define CMD_SET_ADVERT_LATLON 0x0E -#define CMD_REMOVE_CONTACT 0x0F -#define CMD_SHARE_CONTACT 0x10 -#define CMD_EXPORT_CONTACT 0x11 -#define CMD_IMPORT_CONTACT 0x12 -#define CMD_REBOOT 0x13 -#define CMD_GET_BATT_AND_STORAGE 0x14 -#define CMD_SET_TUNING_PARAMS 0x15 -#define CMD_DEVICE_QUERY 0x16 -#define CMD_EXPORT_PRIVATE_KEY 0x17 -#define CMD_IMPORT_PRIVATE_KEY 0x18 -#define CMD_SEND_RAW_DATA 0x19 -#define CMD_SEND_LOGIN 0x1A -#define CMD_SEND_STATUS_REQ 0x1B -#define CMD_HAS_CONNECTION 0x1C -#define CMD_LOGOUT 0x1D -#define CMD_GET_CONTACT_BY_KEY 0x1E -#define CMD_GET_CHANNEL 0x1F -#define CMD_SET_CHANNEL 0x20 -#define CMD_SIGN_START 0x21 -#define CMD_SIGN_DATA 0x22 -#define CMD_SIGN_FINISH 0x23 -#define CMD_SEND_TRACE_PATH 0x24 -#define CMD_SET_DEVICE_PIN 0x25 -#define CMD_SET_OTHER_PARAMS 0x26 -#define CMD_SEND_TELEMETRY_REQ 0x27 -#define CMD_GET_CUSTOM_VARS 0x28 -#define CMD_SET_CUSTOM_VAR 0x29 -#define CMD_GET_ADVERT_PATH 0x2A -#define CMD_GET_TUNING_PARAMS 0x2B -#define CMD_SEND_BINARY_REQ 0x32 -#define CMD_FACTORY_RESET 0x33 -#define CMD_SEND_PATH_DISCOVERY 0x34 -#define CMD_SET_FLOOD_SCOPE_KEY 0x36 /* v8+ (renamed from CMD_SET_FLOOD_SCOPE) */ -#define CMD_SEND_CONTROL_DATA 0x37 -#define CMD_GET_STATS 0x38 -#define CMD_SEND_ANON_REQ 0x39 -#define CMD_SET_AUTOADD_CONFIG 0x3A -#define CMD_GET_AUTOADD_CONFIG 0x3B -#define CMD_GET_ALLOWED_REPEAT_FREQ 0x3C -#define CMD_SET_PATH_HASH_MODE 0x3D -#define CMD_SEND_CHANNEL_DATA 0x3E -#define CMD_SET_DEFAULT_FLOOD_SCOPE 0x3F /* v11+ */ -#define CMD_GET_DEFAULT_FLOOD_SCOPE 0x40 /* v11+ */ -#define CMD_SEND_RAW_PACKET 0x41 /* v12+ */ -#define CMD_RUN_CLI_COMMAND 0x42 /* v14+ */ - -/* Response packet types */ -#define PACKET_OK 0x00 -#define PACKET_ERROR 0x01 -#define PACKET_CONTACT_START 0x02 -#define PACKET_CONTACT 0x03 -#define PACKET_CONTACT_END 0x04 -#define PACKET_SELF_INFO 0x05 -#define PACKET_SENT 0x06 -#define PACKET_CONTACT_MSG_RECV 0x07 /* Legacy - ver < 3 */ -#define PACKET_CHANNEL_MSG_RECV 0x08 /* Legacy - ver < 3 */ -#define PACKET_CURR_TIME 0x09 -#define PACKET_NO_MORE_MSGS 0x0A -#define PACKET_EXPORT_CONTACT 0x0B -#define PACKET_BATTERY 0x0C -#define PACKET_DEVICE_INFO 0x0D -#define PACKET_PRIVATE_KEY 0x0E -#define PACKET_DISABLED 0x0F -#define PACKET_CONTACT_MSG_V3 0x10 /* Contact message for app ver >= 3 */ -#define PACKET_CHANNEL_MSG_V3 0x11 /* Channel message for app ver >= 3 */ -#define PACKET_CHANNEL_INFO 0x12 -#define PACKET_SIGN_START 0x13 -#define PACKET_SIGNATURE 0x14 -#define PACKET_CUSTOM_VARS 0x15 -#define PACKET_ADVERT_PATH 0x16 -#define PACKET_TUNING_PARAMS 0x17 -#define PACKET_STATS 0x18 -#define PACKET_AUTOADD_CONFIG 0x19 -#define PACKET_ALLOWED_REPEAT_FREQ 0x1A -#define PACKET_CHANNEL_DATA_RECV 0x1B -#define PACKET_DEFAULT_FLOOD_SCOPE 0x1C -#define PACKET_CLI_REPLY 0x1D /* v14+, reply to CMD_RUN_CLI_COMMAND */ - -#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9) - -#define MAX_SIGN_DATA_LEN (8 * 1024) - -/* Error codes */ -#define ERR_UNSUPPORTED 0x01 -#define ERR_NOT_FOUND 0x02 -#define ERR_TABLE_FULL 0x03 -#define ERR_BAD_STATE 0x04 -#define ERR_ILLEGAL_ARG 0x06 - -/* Max LoRa TX power (dBm) for SX1262 */ -#define MAX_LORA_TX_POWER 22 - -/* Helper to put little-endian values */ -static inline void put_le16(uint8_t *p, uint16_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; } -static inline void put_le32(uint8_t *p, uint32_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; p[2] = (v >> 16) & 0xFF; p[3] = (v >> 24) & 0xFF; } -static inline uint32_t get_le32(const uint8_t *p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } +#include "CompanionProtocol.h" /* UI notification helper => path_len extraction + uniform call through ui_task.h */ #if ZEPHCORE_HAS_UI_TASK @@ -383,16 +273,6 @@ void CompanionMesh::timeSyncTick() _timesync.runTick(*getRTCClock()); } -void CompanionMesh::onLoginSent(const ContactInfo &contact) -{ - memcpy(&_pending_login, contact.id.pub_key, 4); -} - -void CompanionMesh::onChannelAdded(ChannelDetails *) -{ - markChannelsDirty(); -} - void CompanionMesh::markContactsDirty(bool substantive) { int64_t deadline = _ms->getMillis() + @@ -448,13 +328,13 @@ bool CompanionMesh::writeFrame(const uint8_t *data, size_t len) return false; } -void CompanionMesh::sendPacketOk() +void CompanionMesh::writeOKFrame() { uint8_t rsp[] = { PACKET_OK }; writeFrame(rsp, sizeof(rsp)); } -void CompanionMesh::sendPacketError(uint8_t code) +void CompanionMesh::writeErrFrame(uint8_t code) { uint8_t rsp[] = { PACKET_ERROR, code }; writeFrame(rsp, sizeof(rsp)); @@ -545,9 +425,9 @@ void CompanionMesh::drainPendingChannelInfos() } } -void CompanionMesh::queueOfflineMessage(const uint8_t *data, size_t len) +void CompanionMesh::addToOfflineQueue(const uint8_t *data, size_t len) { - LOG_DBG("queueOfflineMessage: len=%u type=0x%02x count_before=%d", (unsigned)len, data[0], _offline_queue_count); + LOG_DBG("addToOfflineQueue: len=%u type=0x%02x count_before=%d", (unsigned)len, data[0], _offline_queue_count); if (_offline_queue_count >= OFFLINE_QUEUE_SIZE) { // Queue full - try to drop oldest channel message first int pos = _offline_queue_head; @@ -576,18 +456,7 @@ void CompanionMesh::queueOfflineMessage(const uint8_t *data, size_t len) memcpy(f->buf, data, f->len); _offline_queue_tail = (_offline_queue_tail + 1) % OFFLINE_QUEUE_SIZE; _offline_queue_count++; - LOG_DBG("queueOfflineMessage: count_after=%d", _offline_queue_count); -} - -bool CompanionMesh::dequeueOfflineMessage(uint8_t *dest, size_t &len) -{ - if (_offline_queue_count == 0) return false; - QueuedFrame *f = &_offline_queue[_offline_queue_head]; - len = f->len; - memcpy(dest, f->buf, len); - _offline_queue_head = (_offline_queue_head + 1) % OFFLINE_QUEUE_SIZE; - _offline_queue_count--; - return true; + LOG_DBG("addToOfflineQueue: count_after=%d", _offline_queue_count); } bool CompanionMesh::peekOfflineMessage(uint8_t *dest, size_t &len) @@ -927,15 +796,15 @@ void CompanionMesh::onMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, contact.name, sender_timestamp, text); markConnectionActive(contact); - queueContactMessage(contact, pkt, TXT_TYPE_PLAIN, sender_timestamp, nullptr, 0, text); + queueMessage(contact, TXT_TYPE_PLAIN, pkt, sender_timestamp, nullptr, 0, text); pushMsgWaiting(); #if ZEPHCORE_HAS_UI_TASK notify_contact_msg_ui(contact, pkt, text, _offline_queue_count); #endif } -void CompanionMesh::queueContactMessage(const ContactInfo &contact, mesh::Packet *pkt, - uint8_t txt_type, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text) +void CompanionMesh::queueMessage(const ContactInfo &contact, uint8_t txt_type, mesh::Packet *pkt, + uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text) { // Build incoming message frame for offline queue uint8_t frame[MAX_FRAME_SIZE]; @@ -979,518 +848,8 @@ void CompanionMesh::queueContactMessage(const ContactInfo &contact, mesh::Packet memcpy(&frame[i], text, text_len); i += text_len; - LOG_DBG("queueContactMessage: frame_len=%d type=0x%02x", i, frame[0]); - queueOfflineMessage(frame, i); -} - -/* ========== V-contact: loopback admin contact ========== - * A synthesized CHAT contact ("v") that exists only toward the - * connected BLE/USB app. Chat messages to it run the text CLI; replies and - * unsolicited notices (battery alert, restart reason) come back as normal - * contact messages via the offline queue. Invariants: - * - never enters the real contacts table (and thus never the RF RX - * matching path) — CMD_ADD_UPDATE_CONTACT is intercepted to a no-op OK; - * - no packet object is ever created for it — interception happens before - * any sendMessage/sendRequest path, so nothing can reach the radio; - * - its pubkey is SHA256-derived with no private key, so even hand-crafted - * over-the-air traffic addressed to it is undecryptable and dropped. */ - -void CompanionMesh::buildVContact(ContactInfo &c) const -{ - memcpy(c.id.pub_key, _vcontact_pubkey, PUB_KEY_SIZE); - c.type = ADV_TYPE_CHAT; - /* App-owned flags (bit 0 = favourite, upper bits = telemetry permissions). - * The v-contact has no contacts-table record to hold them, so they live in - * prefs — see the CMD_ADD_UPDATE_CONTACT interception below. Echoing a - * hardcoded 0 here is what used to clear the favourite star on every - * contact sync. */ - c.flags = prefs.v_contact_flags; - c.out_path_len = 0; /* zero-hop direct — renders as "0 hops" in the app */ - c.shared_secret_valid = false; - memset(c.out_path, 0, sizeof(c.out_path)); - c.name[0] = 'v'; - StrHelper::strzcpy(&c.name[1], prefs.node_name, sizeof(c.name) - 1); - c.last_advert_timestamp = _vcontact_lastmod; - c.lastmod = _vcontact_lastmod; - c.gps_lat = 0; - c.gps_lon = 0; - c.sync_since = 0; -} - -bool CompanionMesh::isVContactKey(const uint8_t *key, int prefix_len) const -{ - if (!isVContactEnabled()) return false; - if (prefix_len > PUB_KEY_SIZE) prefix_len = PUB_KEY_SIZE; - return memcmp(key, _vcontact_pubkey, prefix_len) == 0; -} - -bool CompanionMesh::vcontactClockValid() -{ - /* Anything before the firmware build epoch is a never-synced clock. */ - return (uint32_t)getRTCClock()->getCurrentTime() >= (uint32_t)FIRMWARE_BUILD_EPOCH; -} - -void CompanionMesh::vcontactClockSynced() -{ - if (!isVContactEnabled() || !vcontactClockValid()) { - return; - } - if (_vcontact_lastmod == 0) { - /* Deferred activation: first valid time source — stamp and announce. - * From here the contact also appears in CMD_GET_CONTACTS syncs. */ - vcontactPushAdvert(); - } - /* Flush notices buffered while the clock was invalid; queueing them now - * gives them real timestamps instead of 1970. */ - for (uint8_t i = 0; i < _vcontact_pending_count; i++) { - vcontactQueueText(_vcontact_pending[i]); - } - _vcontact_pending_count = 0; -} - -void CompanionMesh::vcontactQueueText(const char *text) -{ - /* Offline-queue frames cap at 172 bytes and the V3 header takes 16, so - * split long CLI replies into <=150-char chunks, preferring line breaks. - * Each chunk becomes its own chat message; getCurrentTimeUnique() keeps - * their timestamps strictly increasing so the app orders them. */ - static const size_t CHUNK_MAX = 150; - ContactInfo vc; - buildVContact(vc); - - const char *p = text; - size_t remaining = strlen(text); - while (remaining > 0) { - size_t take = remaining; - if (take > CHUNK_MAX) { - take = CHUNK_MAX; - for (size_t i = take; i > CHUNK_MAX / 2; i--) { - if (p[i - 1] == '\n') { take = i; break; } - } - } - char chunk[CHUNK_MAX + 1]; - memcpy(chunk, p, take); - chunk[take] = '\0'; - size_t adv = take; - for (size_t i = 0; i < take; i++) { - if (chunk[i] == '\r') chunk[i] = ' '; /* CRLF CLI output → LF */ - } - while (take > 0 && (chunk[take - 1] == '\n' || chunk[take - 1] == ' ')) { - chunk[--take] = '\0'; /* trim trailing break of this bubble */ - } - if (take > 0) { - queueContactMessage(vc, nullptr, TXT_TYPE_PLAIN, - getRTCClock()->getCurrentTimeUnique(), nullptr, 0, chunk); - } - p += adv; - remaining -= adv; - } - /* Suppress the "you have messages" prompt during the app's initial sync - * (CMD_APP_START until the first PACKET_NO_MORE_MSGS). Sent mid-sync it - * makes the app interleave message-sync into the contact stream and - * truncate it. The message is already safe in the offline queue and the - * app's own initial message-sync drains it — so no prompt is needed inside - * the window. Outside it, the prompt goes out immediately. - * - * Also suppressed while the app has deleted the v-contact this session: - * prompting for messages from a contact the app just dropped is noise. The - * messages stay in the offline queue and drain on the next connect, when - * the contact is back. */ - if (!vcontactMsgWaitHeld() && !_vcontact_app_hidden) { - pushMsgWaiting(); - } else { - LOG_INF("msgwait: suppressed (hold=%d hidden=%d), %d queued", - (int)_vcontact_hold_msgwait, (int)_vcontact_app_hidden, - _offline_queue_count); - } -} - -void CompanionMesh::vcontactNotify(const char *text) -{ - if (!isVContactEnabled() || !text || !text[0]) return; - LOG_INF("vcontact notify: %s", text); - if (!vcontactClockValid()) { - /* Clock never synced — a message queued now would show as 1970. - * Buffer it; vcontactClockSynced() flushes with a real timestamp. - * Drop-oldest when full (restart reason + battery is the whole - * expected population). */ - if (_vcontact_pending_count >= (uint8_t)ARRAY_SIZE(_vcontact_pending)) { - memmove(_vcontact_pending[0], _vcontact_pending[1], - sizeof(_vcontact_pending[0]) * (ARRAY_SIZE(_vcontact_pending) - 1)); - _vcontact_pending_count = ARRAY_SIZE(_vcontact_pending) - 1; - } - strncpy(_vcontact_pending[_vcontact_pending_count], text, - sizeof(_vcontact_pending[0]) - 1); - _vcontact_pending[_vcontact_pending_count][sizeof(_vcontact_pending[0]) - 1] = '\0'; - _vcontact_pending_count++; - return; - } - vcontactQueueText(text); -} - -/* How long the delivery-ack is held back after its PACKET_SENT. - * - * A plain timer on purpose. The tempting trigger — "flush as soon as the app - * sends us another frame, that proves it is done with the response" — is - * useless here: we emit MSG_WAITING for the CLI reply a few lines further - * down, and the app answers that with CMD_SYNC_NEXT_MESSAGE inside one - * connection interval. That inbound frame would arrive ~50 ms in and flush - * the confirm almost as early as sending it inline did, which is the bug. - * - * Bounded on the other side by the 3000 ms est_timeout we advertise in - * PACKET_SENT: the confirm must land well before the app gives up and resends, - * or the resend races it. */ -#define VCONTACT_CONFIRM_DELAY_MS 300 - -void CompanionMesh::vcontactConfirmWorkHandler(struct k_work *work) -{ - struct k_work_delayable *dwork = k_work_delayable_from_work(work); - ConfirmWork *cw = CONTAINER_OF(dwork, ConfirmWork, work); - cw->self->vcontactFlushConfirm(); -} - -void CompanionMesh::vcontactFlushConfirm() -{ - /* Single 32-bit read/clear: a concurrent flush from the work handler and - * the frame path can at worst emit the push twice, which the app treats as - * idempotent. Losing it is the failure that matters, so no lock. */ - uint32_t ack = _vcontact_confirm_ack; - if (ack == 0) { - return; - } - _vcontact_confirm_ack = 0; - k_work_cancel_delayable(&_vcontact_confirm_work.work); - - uint8_t ack_push[8]; - memcpy(ack_push, &ack, 4); - memset(&ack_push[4], 0, 4); /* trip time: 0 ms — never left the box */ - LOG_DBG("vcontact: emitting deferred ack 0x%08x", ack); - sendPush(PUSH_CODE_SEND_CONFIRMED, ack_push, 8); -} - -void CompanionMesh::deriveVContactKey() -{ - /* v-contact pubkey = the Ed25519 public point of a keypair derived from - * SHA256("zc-vcontact" || self prv_key || counter). Re-run whenever the - * identity changes (boot, CMD_IMPORT_PRIVATE_KEY) so the key always tracks - * the current identity. - * - * This used to be the bare hash SHA256("zc-vcontact" || self pubkey) placed - * straight into a pub_key field. That is wrong on the wire: a uniformly - * random 32-byte string decompresses to a valid Ed25519 point only about - * half the time (the recovered x^2 must be a quadratic residue), so ~50% of - * nodes advertised a v-contact whose key strict clients reject outright — - * they decompress peer keys on contact upsert and on DM build, and error - * with "peer pub_key is not a valid Ed25519 point". ZephCore itself never - * noticed because it only ever memcmp()s this key (see isVContactKey(); - * buildVContact() sets shared_secret_valid = false). Deriving the point via - * scalarbase instead makes it valid by construction, for every node. - * - * Seeded from the PRIVATE key, not the public one: the seed then sits - * behind a value no one else holds, so an outsider cannot link a v-contact - * to the node it belongs to, and cannot derive the matching private key. - * Nothing needs that link — the app receives the v-contact through an - * explicit PUSH_CODE_NEW_ADVERT and never derives it. The private half is - * computed here and dropped: it is never stored, never signs, and never - * takes part in a key exchange. Publishing the point leaks nothing about - * the identity — reaching prv from it means solving the Ed25519 discrete - * log AND inverting SHA-512 AND inverting SHA-256. - * - * The counter byte serves the reserved-prefix guard: MeshCore treats - * pub_key[0] of 0x00/0xFF as a protocol marker (same rule as - * ZephyrRNG::generateFirstBootIdentity and validatePrivateKey), and a - * deterministic derivation cannot simply "draw again" without one. Each - * bump re-rolls the whole key; P(a single miss) = 2/256, so the loop - * essentially always ends on the first pass and stays deterministic. */ - static const char vc_salt[] = "zc-vcontact"; - uint8_t material[PRV_KEY_SIZE + 1]; - uint8_t seed[SEED_SIZE]; - mesh::LocalIdentity vc; - - /* writeTo()'s buffer format is prv || pub; capping max_len at PRV_KEY_SIZE - * asks for the private half alone. */ - if (self_id.writeTo(material, PRV_KEY_SIZE) != PRV_KEY_SIZE) { - /* Cannot happen — kept so a future signature change fails loudly - * rather than seeding off an uninitialised stack buffer. */ - LOG_ERR("vcontact: private key unavailable, key not derived"); - mesh::Utils::secureZeroize(material, sizeof(material)); - return; - } - - for (int counter = 0; counter < 256; counter++) { - material[PRV_KEY_SIZE] = (uint8_t)counter; - mesh::Utils::sha256(seed, SEED_SIZE, - (const uint8_t *)vc_salt, sizeof(vc_salt) - 1, - material, sizeof(material)); - vc.fromSeed(seed); - if (vc.pub_key[0] != 0x00 && vc.pub_key[0] != 0xFF) break; - } - memcpy(_vcontact_pubkey, vc.pub_key, PUB_KEY_SIZE); - - /* material holds the real identity private key, and vc holds the v-key's - * discarded private half. Neither has any business outliving this call. - * LocalIdentity keeps prv_key private and has no wipe of its own, so the - * object is cleared wholesale — it has no virtuals, so there is no vtable - * pointer to destroy. */ - mesh::Utils::secureZeroize(material, sizeof(material)); - mesh::Utils::secureZeroize(seed, sizeof(seed)); - mesh::Utils::secureZeroize(&vc, sizeof(vc)); -} - -void CompanionMesh::vcontactPushAdvert() -{ - if (!isVContactEnabled()) return; - /* The app deleted it this session — don't push it straight back at them. - * It returns on its own at the next CMD_APP_START. */ - if (_vcontact_app_hidden) return; - if (!vcontactClockValid()) { - /* Defer — an advert stamped now would carry a 1970 timestamp. - * vcontactClockSynced() re-runs this once a time source arrives. */ - return; - } - /* Bump lastmod so incremental contact syncs (since > 0) pick up the - * rename/re-enable. */ - _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); - ContactInfo vc; - buildVContact(vc); - uint8_t rsp[CONTACT_FRAME_SIZE]; - size_t n = serializeContact(rsp, vc); /* no header — push code is separate */ - sendPush(PUSH_CODE_NEW_ADVERT, rsp, n); -} - -void CompanionMesh::vcontactPushDeleted() -{ - sendPush(PUSH_CODE_CONTACT_DELETED, _vcontact_pubkey, PUB_KEY_SIZE); -} - -bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) -{ - if (!isVContactEnabled()) return false; - - switch (data[0]) { - case CMD_SEND_TXT_MSG: - /* Same frame layout as the real handler: cmd(1) + txt_type(1) + - * attempt(1) + timestamp(4) + pub_key_prefix(6) + text(N). */ - if (len >= 14 && isVContactKey(&data[7], 6)) { - uint8_t txt_type = data[1]; - if (txt_type != TXT_TYPE_PLAIN && txt_type != TXT_TYPE_CLI_DATA && - txt_type != TXT_TYPE_CLI_COMMAND) { - sendPacketError(ERR_UNSUPPORTED); - return true; - } - uint32_t msg_timestamp = get_le32(&data[3]); - - /* Extract the text now — needed both for the ack hash below and - * for the CLI call. */ - char line[MAX_TEXT_LEN + 1]; - size_t text_len = len - 13; - if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; - memcpy(line, &data[13], text_len); - line[text_len] = '\0'; - - /* Compute the SAME delivery-ack the app expects for this exact - * (timestamp, attempt, text): sha256 over ts(4) + (attempt&3)(1) + - * text, salted with our pubkey — identical to composeMsgPacket() on - * the real send path. The old code sent a RANDOM ack, which never - * matched the value the app derives locally, so the app treated the - * loopback message as un-acked and resent it once (attempt=1) a few - * seconds later → duplicate reply. - * - * The value is right, but the CONFIRMED that carries it is NOT sent - * inline — see _vcontact_confirm_ack. Sent here it lands - * sub-millisecond after the PACKET_SENT response to the same write, - * before the app has committed the outgoing message, and the app - * drops it. Deferred by a few hundred ms it sticks first time. */ - uint8_t hbuf[5 + MAX_TEXT_LEN]; - memcpy(hbuf, &data[3], 4); /* timestamp, on-wire LE bytes */ - hbuf[4] = (data[2] & 3); /* attempt & 3 */ - memcpy(&hbuf[5], line, text_len); - uint32_t ack = 0; - mesh::Utils::sha256((uint8_t *)&ack, 4, hbuf, 5 + text_len, - self_id.pub_key, PUB_KEY_SIZE); - if (ack == 0) ack = 1; - - sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 3000); - /* One slot, not a queue: a second v-contact message inside - * VCONTACT_CONFIRM_DELAY_MS would overwrite the first ack before - * its work ever ran, so that message stayed un-confirmed until the - * app hit est_timeout and resent it. Flush the pending one first — - * it is already past the sub-millisecond window that made deferring - * necessary, so emitting it now is safe. */ - vcontactFlushConfirm(); - _vcontact_confirm_ack = ack; - k_work_reschedule(&_vcontact_confirm_work.work, - K_MSEC(VCONTACT_CONFIRM_DELAY_MS)); - - /* Dedupe app resends: a retry reuses the message timestamp (only - * the attempt byte changes). The correct ack above should stop most - * retries, but a CONFIRMED lost under BLE congestion still triggers - * one — and it lands after other messages, so match against a ring - * of recent timestamps (a single last-seen slot misses it). A - * surviving retry re-acks (done above) without re-running the CLI. */ - bool dup = false; - if (msg_timestamp != 0) { - for (uint8_t i = 0; i < VCONTACT_DEDUP_SLOTS; i++) { - if (_vcontact_recent_ts[i] == msg_timestamp) { dup = true; break; } - } - if (!dup) { - _vcontact_recent_ts[_vcontact_recent_head] = msg_timestamp; - _vcontact_recent_head = - (_vcontact_recent_head + 1) % VCONTACT_DEDUP_SLOTS; - } - } - - if (!dup) { - /* Phone keyboards autocapitalize the first letter of a - * chat line, so a v-contact command arrives as "Get cad". - * Fold character 0 only. - * - * Safe by construction: character 0 is always inside the - * command verb -- no CLI command takes an argument at - * position 0 -- so this cannot alter a value. Deliberately - * NOT generalized beyond one character: the previous attempt - * folded the first two whitespace-delimited tokens and - * silently lowercased admin passwords (see the comment above - * CommonCLI::handleCommand in helpers/CommonCLI.cpp). - * - * Must stay after the delivery-ack hash above, which covers - * the original text the app will match against. */ - if (line[0] >= 'A' && line[0] <= 'Z') { - line[0] = (char)(line[0] - 'A' + 'a'); - } - - LOG_INF("vcontact CLI: '%s'", line); - char reply[COMPANION_CLI_REPLY_SIZE]; - reply[0] = '\0'; - if (_cli_exec_cb) { - _cli_exec_cb(line, reply); - } else { - strcpy(reply, "CLI not available"); - } - if (reply[0] != '\0') { - vcontactQueueText(reply); - } - } else { - LOG_DBG("vcontact CLI: dup ts=%u, re-ack only", msg_timestamp); - } - return true; - } - return false; - - case CMD_GET_CONTACT_BY_KEY: - if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { - ContactInfo vc; - buildVContact(vc); - uint8_t rsp[CONTACT_FRAME_SIZE]; - size_t n = serializeContact(rsp, vc, PACKET_CONTACT); - writeFrame(rsp, n); - return true; - } - return false; - - case CMD_GET_ADVERT_PATH: - /* The v-contact appears in the app's contact list, so the app queries - * its advert path (at connect, and around a config/identity import). The - * v-contact has no over-the-air advert, so the real handler's - * findAdvertPath() misses and returns ERR_NOT_FOUND — which the app - * surfaces as a fatal "not found" that aborts the whole operation. The - * v-contact IS this node (loopback), so its path is direct (zero hops): - * answer that here, before the real handler runs. Frame layout matches - * the real handler: [cmd][reserved][7-byte pubkey prefix]. */ - if (len >= 2 + 7 && isVContactKey(&data[2], 7)) { - uint8_t rsp[6]; - size_t i = 0; - rsp[i++] = PACKET_ADVERT_PATH; - put_le32(&rsp[i], _vcontact_lastmod ? _vcontact_lastmod - : (uint32_t)getRTCClock()->getCurrentTime()); i += 4; - rsp[i++] = 0; // path_len = 0 → direct (zero hop) - writeFrame(rsp, i); - return true; - } - return false; - - case CMD_ADD_UPDATE_CONTACT: - /* Never let the v-contact into the real contacts table (it must stay out - * of the RF RX matching path) — but do keep the one field the app owns - * and expects back: the flags byte (bit 0 = favourite, upper bits = - * telemetry permissions). Everything else in the frame (name, path, - * lat/lon, advert timestamp) is ours to generate in buildVContact(). - * Frame layout matches the real handler: [cmd][32-byte pubkey][type] - * [flags][...]. Reply OK so app-side flows don't surface errors. */ - if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { - if (len >= 1 + PUB_KEY_SIZE + 2) { - uint8_t flags = data[1 + PUB_KEY_SIZE + 1]; - if (flags != prefs.v_contact_flags) { - prefs.v_contact_flags = flags; - _store->savePrefs(prefs); - } - } - sendPacketOk(); - return true; - } - return false; - - case CMD_RESET_PATH: - /* Path resets are meaningless for a loopback contact — accept and drop - * so app-side flows don't surface errors. */ - if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { - sendPacketOk(); - return true; - } - return false; - - case CMD_REMOVE_CONTACT: - /* App-side delete hides the v-contact for the rest of this session and - * nothing more — see _vcontact_app_hidden. It used to set - * prefs.v_contact_enabled = 0, which made a routine "purge all - * contacts" in the app silently disable the feature for good: the - * v-contact is an ordinary list entry, so a purge removes it like any - * other, and only the USB CLI could turn it back on. Durable disable - * stays with the node-side pref (`set v.contact off`). - * - * Flags are deliberately kept: the same contact returns on the next - * connect, so its favourite star should return with it. */ - if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { - _vcontact_app_hidden = true; - LOG_INF("vcontact: hidden by app delete (this session only)"); - sendPacketOk(); - return true; - } - return false; - - case CMD_SEND_TELEMETRY_REQ: - /* Contact telemetry request: [cmd][3 reserved][32-byte pubkey]. - * The v-contact represents THIS node, so its telemetry is our own - * self-telemetry. Synthesize the SENT ack the app blocks on, then push - * a TELEMETRY_RESPONSE tagged with the v-contact key so the app matches - * it to the loopback contact. (Real contacts fall through to the async - * RF path below; the loopback key just isn't in the contacts table.) */ - if (len >= 4 + PUB_KEY_SIZE && isVContactKey(&data[4], PUB_KEY_SIZE)) { - uint32_t tag = 0; - getRNG()->random((uint8_t *)&tag, 4); - if (tag == 0) tag = 1; - sendPacketSent(MSG_SEND_SENT_DIRECT, tag, 3000); - - uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; - int i = 0; - rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; - rsp[i++] = 0; /* reserved */ - memcpy(&rsp[i], _vcontact_pubkey, 6); - i += 6; - i += appendSelfTelemetry(&rsp[i], - TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT); - sendPush(rsp[0], &rsp[1], i - 1); - return true; - } - return false; - - default: - /* Every other pubkey-addressed opcode (login, binary req, - * path discovery, export, ...) resolves the contact via - * lookupContactByPubKey(); the v-contact is never in the table, so - * they fail with ERR_NOT_FOUND before any packet exists. */ - return false; - } + LOG_DBG("queueMessage: frame_len=%d type=0x%02x", i, frame[0]); + addToOfflineQueue(frame, i); } void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, @@ -1540,7 +899,7 @@ void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, i += text_len; LOG_DBG("queueLocalSentContactMessage: frame_len=%d delivered=%d", i, (int)delivered); - queueOfflineMessage(frame, i); + addToOfflineQueue(frame, i); pushMsgWaiting(); } @@ -1586,7 +945,7 @@ void CompanionMesh::queueLocalSentChannelMessage(uint8_t channel_idx, LOG_DBG("queueLocalSentChannelMessage: frame_len=%d channel_idx=%d heard=%d", i, channel_idx, (int)heard_repeat); - queueOfflineMessage(frame, i); + addToOfflineQueue(frame, i); pushMsgWaiting(); } @@ -1597,7 +956,7 @@ void CompanionMesh::onCommandDataRecv(const ContactInfo &contact, mesh::Packet * contact.name, sender_timestamp, text); markConnectionActive(contact); - queueContactMessage(contact, pkt, TXT_TYPE_CLI_DATA, sender_timestamp, nullptr, 0, text); + queueMessage(contact, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, nullptr, 0, text); pushMsgWaiting(); { struct ui_joystick_cli_data cli = { text }; @@ -1605,6 +964,74 @@ void CompanionMesh::onCommandDataRecv(const ContactInfo &contact, mesh::Packet * } } +void CompanionMesh::onCLICommandRecv(const ContactInfo &contact, mesh::Packet *pkt, + uint32_t sender_timestamp, const char *text, char *reply) +{ + markConnectionActive(contact); // in case this is from a server, and we have a connection + if (contact.isRemoteCLIAllowed()) { + if (!handleCommand(text, sender_timestamp, reply)) { + strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text' + } + } else { + queueMessage(contact, TXT_TYPE_CLI_COMMAND, pkt, sender_timestamp, nullptr, 0, text); + pushMsgWaiting(); + } +} + +/* Over the air, a contact with flag 0x10 reaches the commands upstream's + * companion handles (MyMesh::handleCommand and CommonRadioPrefs) and nothing + * else: the ZephCore-only commands (start ota/dfu, erase, prv.key, clkreboot, + * v.contact, autoshutdown, ...) stay local. */ +static bool isUpstreamCompanionCommand(const char *cmd) +{ + static const char *const exact[] = { + "get radio", "get freq", "get af", "get dutycycle", "get int.thresh", "get cad", + "get radio.rxgain", "get tx", "get rxdelay", "get agc.reset.interval", + "get path.hash.mode", "get multi.acks", "get txdelay", "get direct.txdelay", + "reboot", "poweroff", "shutdown", "get name", "get wifi.pwd", "set wifi.clear", + "get wifi.ssid", "get wifi.enabled", "get wifi.status", "get wifi.ip", + "board", "ver", "get tz.offset", + }; + static const char *const prefix[] = { + "set radio ", "set af ", "set dutycycle ", "set int.thresh ", "set cad ", + "set radio.rxgain ", "get tx ", "set tx ", "set rxdelay ", "set agc.reset.interval ", + "set path.hash.mode ", "set multi.acks ", "set txdelay ", "set direct.txdelay ", + "set name ", "set pin ", "set wifi.ssid ", "set wifi.pwd ", "set wifi.enabled ", + "set tz.offset ", + }; + for (const char *e : exact) { + if (strcmp(cmd, e) == 0) return true; + } + for (const char *p : prefix) { + if (strncmp(cmd, p, strlen(p)) == 0) return true; + } + return false; +} + +bool CompanionMesh::handleCommand(const char *command, uint32_t sender_timestamp, char *reply) +{ + while (*command == ' ') command++; // skip leading spaces + + uint8_t hdr_used = 0; + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) + memcpy(reply, command, 3); // reflect the prefix back + reply += 3; + command += 3; + hdr_used = 3; + } + *reply = 0; + + if (sender_timestamp != 0 && !isUpstreamCompanionCommand(command)) { + return false; + } + if (_cli_exec_cb == nullptr) { + strcpy(reply, "CLI not available"); + return true; + } + _cli_exec_cb(command, sender_timestamp, hdr_used, reply); + return *reply != 0; +} + void CompanionMesh::onSignedMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) { @@ -1614,7 +1041,7 @@ void CompanionMesh::onSignedMessageRecv(const ContactInfo &contact, mesh::Packet markConnectionActive(contact); markContactsDirty(); // sender_prefix is 4 bytes - queueContactMessage(contact, pkt, TXT_TYPE_SIGNED_PLAIN, sender_timestamp, sender_prefix, 4, text); + queueMessage(contact, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text); pushMsgWaiting(); #if ZEPHCORE_HAS_UI_TASK notify_contact_msg_ui(contact, pkt, text, _offline_queue_count); @@ -1684,7 +1111,7 @@ void CompanionMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh i += text_len; LOG_DBG("onChannelMessageRecv: frame_len=%d channel_idx=%d", i, channel_idx); - queueOfflineMessage(frame, i); + addToOfflineQueue(frame, i); pushMsgWaiting(); #if ZEPHCORE_HAS_UI_TASK { @@ -1729,7 +1156,7 @@ void CompanionMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::P LOG_DBG("onChannelDataRecv: frame_len=%d channel_idx=%d data_type=%d", i, channel_idx, (int)data_type); - queueOfflineMessage(frame, i); + addToOfflineQueue(frame, i); pushMsgWaiting(); } @@ -2341,7 +1768,7 @@ const AdvertPath *CompanionMesh::findAdvertPath(const uint8_t *pubkey_prefix, in return nullptr; } -bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) +bool CompanionMesh::handleCmdFrame(const uint8_t *data, size_t len) { if (len < 1) return false; @@ -2362,1600 +1789,1758 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) } switch (data[0]) { - case CMD_APP_START: { - if (len < 8) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - LOG_INF("CMD_APP_START: session reset, %d queued", _offline_queue_count); - // Reset per-session state for a fresh app session. BLE/USB also run - // this cleanup on disconnect, but the serial transport has no - // disconnect event, so APP_START is its authoritative session-reset - // point: drop a stale contact iteration (a leftover PACKET_CONTACT_END - // would confuse the new session's sync state machine), a half-finished - // message sync, and free an abandoned Ed25519 sign buffer (else an 8KB - // leak if the previous session dropped mid-CMD_SIGN). Idempotent — all - // no-ops when the state is already clean. - _contact_iter_active = false; - cancelSyncPending(); - cleanupSignState(); - /* Drop a deferred v-contact confirm from the previous session: its ack - * matches an outbound message the new session never sent, so delivering - * it here would push an unmatched SEND_CONFIRMED into a fresh app. */ - _vcontact_confirm_ack = 0; - k_work_cancel_delayable(&_vcontact_confirm_work.work); - - /* New session: suppress v-contact notice MSG_WAITING until the initial - * sync (contacts + messages) completes at PACKET_NO_MORE_MSGS, or the - * deadline expires — see _vcontact_hold_expiry for why the latch needs - * a second exit. */ - _vcontact_hold_msgwait = true; - _vcontact_hold_expiry = _ms->getMillis() + VCONTACT_HOLD_MAX_MS; - /* An app-side delete only hides the v-contact for the session it - * happened in — this is that session boundary, so it comes back. */ - _vcontact_app_hidden = false; - - /* If a time source already ran (hardware RTC, GPS), activate the - * deferred v-contact and flush buffered notices for this session. */ - vcontactClockSynced(); - - /* Re-stamp the v-contact once per app session so it stays "fresh". - * _vcontact_lastmod feeds both lastmod and last_advert_timestamp, and - * it used to move only on boot / rename / identity import: the app - * showed an ever-growing "last seen" age, and — worse — the contact - * sync gate is `_vcontact_lastmod > _contact_iter_since`, so after the - * first sync the v-contact was never streamed again and app-side state - * (flags, name) could never be corrected. Bumping here, before the - * CMD_GET_CONTACTS that follows APP_START, means every session's sync - * carries a current timestamp; deferring it to the end of sync would - * always land one session late. Silent on purpose — no NEW_ADVERT push - * mid-handshake; the sync itself delivers it. */ - if (vcontactReady() && vcontactClockValid()) { - _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); - } - - // Return SELF_INFO - uint8_t rsp[90]; // 58 fixed + up to 32 bytes name - size_t i = 0; - rsp[i++] = PACKET_SELF_INFO; - rsp[i++] = ADV_TYPE_CHAT; - rsp[i++] = prefs.tx_power_dbm; - rsp[i++] = MAX_LORA_TX_POWER; // Max allowed TX power - memcpy(&rsp[i], self_id.pub_key, PUB_KEY_SIZE); i += PUB_KEY_SIZE; - int32_t lat = (int32_t)(prefs.node_lat * 1000000.0); - int32_t lon = (int32_t)(prefs.node_lon * 1000000.0); - put_le32(&rsp[i], lat); i += 4; - put_le32(&rsp[i], lon); i += 4; - rsp[i++] = prefs.multi_acks; - rsp[i++] = prefs.advert_loc_policy; - // Telemetry modes: (env << 4) | (loc << 2) | base - rsp[i++] = (prefs.telemetry_mode_env << 4) | (prefs.telemetry_mode_loc << 2) | prefs.telemetry_mode_base; - rsp[i++] = prefs.manual_add_contacts; - put_le32(&rsp[i], (uint32_t)(prefs.freq * 1000)); i += 4; - put_le32(&rsp[i], (uint32_t)(prefs.bw * 1000)); i += 4; - rsp[i++] = prefs.sf; - rsp[i++] = prefs.cr; - size_t name_len = strnlen(prefs.node_name, sizeof(prefs.node_name) - 1); - memcpy(&rsp[i], prefs.node_name, name_len); - i += name_len; - writeFrame(rsp, i); - return true; - } - - case CMD_GET_CONTACTS: - if (_contact_iter_active) { - sendPacketError(ERR_BAD_STATE); - } else { - // Parse optional 'since' parameter for incremental sync - if (len >= 5) { - _contact_iter_since = get_le32(&data[1]); - } else { - _contact_iter_since = 0; - } - - // Send PACKET_CONTACT_START with total count (unfiltered, but excluding - // transient anon slots -- continueContactIteration() never streams those) - _contact_iter_num = getNumContacts(); - _contact_iter_vc = vcontactReady(); /* virtual tail entry (post time sync) */ - uint32_t total = 0; - for (int i = 0; i < _contact_iter_num; i++) { - ContactInfo c; - if (getContactByIdx(i, c) && c.type != ADV_TYPE_NONE) total++; - } - if (_contact_iter_vc) total++; - uint8_t rsp[5]; - rsp[0] = PACKET_CONTACT_START; - put_le32(&rsp[1], total); - writeFrame(rsp, sizeof(rsp)); - _contact_iter_idx = 0; - _contact_iter_lastmod = 0; - _contact_iter_active = true; - } - return true; - - case CMD_ADD_UPDATE_CONTACT: - if (len >= 1 + PUB_KEY_SIZE + 1 + 1 + 1 + MAX_PATH_SIZE + 32 + 4) { - const uint8_t *pub_key = &data[1]; - ContactInfo *existing = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - - ContactInfo c; - size_t i = 1; - memcpy(c.id.pub_key, &data[i], PUB_KEY_SIZE); i += PUB_KEY_SIZE; - c.type = data[i++]; - c.flags = data[i++]; - c.out_path_len = data[i++]; - memcpy(c.out_path, &data[i], MAX_PATH_SIZE); i += MAX_PATH_SIZE; - memcpy(c.name, &data[i], 32); i += 32; - c.name[31] = '\0'; /* defensive null-term — matches CMD_SET_CHANNEL pattern */ - c.last_advert_timestamp = get_le32(&data[i]); i += 4; - c.gps_lat = 0; - c.gps_lon = 0; - c.lastmod = (uint32_t)getRTCClock()->getCurrentTime(); - if (len >= i + 8) { - c.gps_lat = (int32_t)get_le32(&data[i]); i += 4; - c.gps_lon = (int32_t)get_le32(&data[i]); i += 4; - if (len >= i + 4) { - c.lastmod = get_le32(&data[i]); - } - } - c.sync_since = 0; - c.shared_secret_valid = false; - - if (existing) { - // Update existing - *existing = c; - markContactsDirty(); - sendPacketOk(); - } else if (addContact(c)) { - markContactsDirty(); - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_REMOVE_CONTACT: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (c && removeContact(*c)) { - _store->deleteBlobByKey(&data[1], PUB_KEY_SIZE); - markContactsDirty(); - sendPacketOk(); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_RESET_PATH: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (c) { - resetPathTo(*c); - markContactsDirty(); - sendPacketOk(); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_GET_CONTACT_BY_KEY: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (c) { - uint8_t rsp[CONTACT_FRAME_SIZE]; - size_t n = serializeContact(rsp, *c, PACKET_CONTACT); - writeFrame(rsp, n); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_GET_CHANNEL: - // Format: [cmd][channel_idx] - // Response: [code][idx][32 name][16 secret] = 50 bytes (matches Arduino) - // Arduino returns channel info even if slot is empty (name[0]=='\0') - if (len < 2 || data[1] >= MAX_GROUP_CHANNELS) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - { - static int64_t last_get_channel_ms; - int64_t now_ms = _ms->getMillis(); - uint32_t dt_ms = last_get_channel_ms ? (uint32_t)(now_ms - last_get_channel_ms) : 0; - last_get_channel_ms = now_ms; - LOG_DBG("CMD_GET_CHANNEL idx=%u dt=%ums", data[1], dt_ms); - drainPendingChannelInfos(); - if (!enqueuePendingChannelInfo(data[1])) { - sendPacketError(ERR_BAD_STATE); - return true; - } - drainPendingChannelInfos(); - return true; - } - - case CMD_SET_CHANNEL: - // Format: [cmd][idx][32 name][16 or 32 secret] - // Arduino rejects 32-byte secrets, but we accept 16-byte (50 bytes total) - if (len < 2 || data[1] >= MAX_GROUP_CHANNELS) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - if (len >= 2 + 32 + 32) { - // 32-byte secret not supported (matches Arduino) - sendPacketError(ERR_UNSUPPORTED); - return true; - } - if (len >= 2 + 32 + 16) { - ChannelDetails ch; - // Copy name (null-terminate if needed) - memcpy(ch.name, &data[2], 32); - ch.name[31] = '\0'; // ensure null termination - // Copy 16-byte secret, zero upper 16 bytes - memset(ch.channel.secret, 0, sizeof(ch.channel.secret)); - memcpy(ch.channel.secret, &data[2 + 32], 16); - // setChannel computes the hash based on whether upper 16 bytes are zero - if (setChannel(data[1], ch)) { - markChannelsDirty(); - sendPacketOk(); - } else { - sendPacketError(ERR_NOT_FOUND); // bad channel_idx - } - return true; - } - sendPacketError(ERR_ILLEGAL_ARG); - return true; - - case CMD_GET_BATT_AND_STORAGE: { - uint8_t rsp[11]; - rsp[0] = PACKET_BATTERY; - put_le16(&rsp[1], _batt_cb ? _batt_cb() : 0); - put_le32(&rsp[3], _store->getStorageUsedKb()); - put_le32(&rsp[7], _store->getStorageTotalKb()); - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_GET_STATS: - if (len >= 2) { - uint8_t stats_type = data[1]; - if (stats_type == 0) { - // STATS_TYPE_CORE: battery_mv(2) + uptime_secs(4) + err_flags(2) + queue_len(1) - uint8_t rsp[11]; - size_t i = 0; - rsp[i++] = PACKET_STATS; - rsp[i++] = 0; // STATS_TYPE_CORE - put_le16(&rsp[i], _batt_cb ? _batt_cb() : 0); i += 2; - put_le32(&rsp[i], (uint32_t)(_ms->getMillis() / 1000)); i += 4; // uptime_secs - put_le16(&rsp[i], getErrFlags()); i += 2; - rsp[i++] = (uint8_t)_mgr->getOutboundCount((uint32_t)_ms->getMillis()); - writeFrame(rsp, i); - } else if (stats_type == 1) { - // STATS_TYPE_RADIO: noise_floor(2) + last_rssi(1) + last_snr(1) + tx_air_secs(4) + rx_air_secs(4) - uint8_t rsp[14]; - size_t i = 0; - rsp[i++] = PACKET_STATS; - rsp[i++] = 1; // STATS_TYPE_RADIO - put_le16(&rsp[i], (int16_t)_radio->getNoiseFloor()); i += 2; - rsp[i++] = (int8_t)_radio->getLastRSSI(); - rsp[i++] = (int8_t)(_radio->getLastSNR() * 4); // scaled by 4 - put_le32(&rsp[i], getTotalAirTime() / 1000); i += 4; // tx_air_secs - put_le32(&rsp[i], getReceiveAirTime() / 1000); i += 4; // rx_air_secs - writeFrame(rsp, i); - } else if (stats_type == 2) { - // STATS_TYPE_PACKETS: recv(4) + sent(4) + sent_flood(4) + sent_direct(4) + recv_flood(4) + recv_direct(4) + recv_errors(4) - uint8_t rsp[30]; - size_t i = 0; - rsp[i++] = PACKET_STATS; - rsp[i++] = 2; // STATS_TYPE_PACKETS - uint32_t sent_flood = getNumSentFlood(); - uint32_t sent_direct = getNumSentDirect(); - uint32_t recv_flood = getNumRecvFlood(); - uint32_t recv_direct = getNumRecvDirect(); - put_le32(&rsp[i], recv_flood + recv_direct); i += 4; // total recv - put_le32(&rsp[i], sent_flood + sent_direct); i += 4; // total sent - put_le32(&rsp[i], sent_flood); i += 4; - put_le32(&rsp[i], sent_direct); i += 4; - put_le32(&rsp[i], recv_flood); i += 4; - put_le32(&rsp[i], recv_direct); i += 4; - put_le32(&rsp[i], _radio->getPacketsRecvErrors()); i += 4; - writeFrame(rsp, i); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SHARE_CONTACT: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (c && shareContactZeroHop(*c)) { - sendPacketOk(); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_EXPORT_CONTACT: - if (len < 1 + PUB_KEY_SIZE) { - // Export SELF - create self advert packet - mesh::Packet *pkt; - if (prefs.advert_loc_policy == 0) { // ADVERT_LOC_NONE - pkt = createSelfAdvert(prefs.node_name); - } else { - pkt = createSelfAdvert(prefs.node_name, prefs.node_lat, prefs.node_lon); - } - if (pkt) { - pkt->header |= ROUTE_TYPE_FLOOD; - uint8_t rsp[128]; - rsp[0] = PACKET_EXPORT_CONTACT; - uint8_t out_len = pkt->writeTo(&rsp[1]); - releasePacket(pkt); - writeFrame(rsp, out_len + 1); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - // Export specific contact by pubkey - ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (c) { - uint8_t rsp[128]; - rsp[0] = PACKET_EXPORT_CONTACT; - uint8_t export_len = exportContact(*c, &rsp[1]); - if (export_len > 0) { - writeFrame(rsp, export_len + 1); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } - return true; - - case CMD_IMPORT_CONTACT: - // Arduino: len > 2 + 32 + 64 = 98 bytes (header + pubkey + signature) - if (len > 2 + 32 + 64) { - if (importContact(&data[1], len - 1)) { - markContactsDirty(); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); // Match Arduino error code - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_TXT_MSG: - // Frame format: cmd(1) + txt_type(1) + attempt(1) + timestamp(4) + pub_key_prefix(6) + text(N) - // Minimum: 1 + 1 + 1 + 4 + 6 + 1 = 14 bytes - LOG_DBG("CMD_SEND_TXT_MSG: len=%u (min=14)", (unsigned)len); - if (len >= 14) { - int i = 1; - uint8_t txt_type = data[i++]; - uint8_t attempt = data[i++]; - uint32_t msg_timestamp = get_le32(&data[i]); i += 4; - const uint8_t *pub_key_prefix = &data[i]; i += 6; - - // Copy text and ensure null termination - size_t text_len = len - 13; // Everything after header (13 bytes) - char text_buf[MAX_TEXT_LEN + 1]; - if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; - memcpy(text_buf, &data[i], text_len); - text_buf[text_len] = '\0'; - const char *text = text_buf; - - LOG_DBG("CMD_SEND_TXT_MSG: txt_type=%d attempt=%d pubkey=%02x%02x%02x%02x%02x%02x", - txt_type, attempt, pub_key_prefix[0], pub_key_prefix[1], pub_key_prefix[2], - pub_key_prefix[3], pub_key_prefix[4], pub_key_prefix[5]); - - ContactInfo *contact = lookupContactByPubKey(pub_key_prefix, 6); - if (contact && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || - txt_type == TXT_TYPE_CLI_COMMAND)) { - LOG_DBG("CMD_SEND_TXT_MSG: contact='%s' text='%s' text_len=%u", contact->name, text, (unsigned)text_len); - - uint32_t expected_ack = 0, est_timeout; - int result; - - if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { - // Use node's RTC instead of app timestamp - msg_timestamp = getRTCClock()->getCurrentTimeUnique(); - result = sendCommandData(*contact, msg_timestamp, attempt, txt_type, text, - est_timeout); - } else { - result = sendMessage(*contact, msg_timestamp, attempt, text, expected_ack, est_timeout); - } - - LOG_DBG("CMD_SEND_TXT_MSG: sendMessage result=%d expected_ack=0x%08x", result, expected_ack); - - if (result != MSG_SEND_FAILED) { - if (expected_ack) { - // Track ACK - int idx = -1; - ContactInfo ci; - for (int k = 0; k < getNumContacts(); k++) { - if (getContactByIdx(k, ci) && ci.id.matches(contact->id)) { - idx = k; - break; - } - } - addPendingAck(expected_ack, idx); - } - - // Response: RESP_CODE_SENT + is_flood(1) + expected_ack(4) + est_timeout(4) - sendPacketSent(result, expected_ack, est_timeout); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - LOG_WRN("CMD_SEND_TXT_MSG: contact not found or unsupported txt_type=%d", txt_type); - sendPacketError(contact == nullptr ? ERR_NOT_FOUND : ERR_UNSUPPORTED); - } - return true; - } - LOG_WRN("CMD_SEND_TXT_MSG: frame too short (len=%u, need 14)", (unsigned)len); - sendPacketError(ERR_ILLEGAL_ARG); - return true; - - case CMD_SEND_CHANNEL_TXT_MSG: - // Arduino format: [cmd][txt_type][channel_idx][4-byte timestamp][text...] - // Minimum: 1 + 1 + 1 + 4 + 1 = 8 bytes - if (len < 8) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - { - int i = 1; - uint8_t txt_type = data[i++]; - uint8_t ch_idx = data[i++]; - uint32_t msg_timestamp = get_le32(&data[i]); - i += 4; - const char *text = (const char *)&data[i]; - size_t text_len = len - i; - - if (txt_type != TXT_TYPE_PLAIN) { - sendPacketError(ERR_UNSUPPORTED); - } else { - ChannelDetails ch; - if (getChannel(ch_idx, ch)) { - if (sendGroupMessage(msg_timestamp, ch.channel, prefs.node_name, text, text_len)) { - sendPacketOk(); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } - return true; - } - - case CMD_SEND_SELF_ADVERT: { - /* Optional param: data[1] == 1 means flood, else zero-hop */ - bool flood = (len >= 2 && data[1] == 1); - if (sendSelfAdvert(flood)) { - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - return true; - } - - case CMD_SYNC_NEXT_MESSAGE: { - LOG_DBG("CMD_SYNC_NEXT_MESSAGE: queue_count=%d pending=%d", - _offline_queue_count, _sync_pending); - - /* The app is draining — hold the watchdog off for another window. */ - _last_sync_req_ms = _ms->getMillis(); - LOG_INF("msgwait: app asked for next msg, %d queued", - _offline_queue_count); - - /* Phone asking for next = implicit ACK for the previously-peeked message */ - if (_sync_pending) { - confirmOfflineMessage(); - _sync_pending = false; -#if ZEPHCORE_HAS_UI_TASK - ui_set_msg_count((uint16_t)_offline_queue_count); -#endif - } - - uint8_t buf[MAX_FRAME_SIZE]; - size_t msg_len; - if (peekOfflineMessage(buf, msg_len)) { - LOG_DBG("CMD_SYNC_NEXT_MESSAGE: peeked msg_len=%u type=0x%02x", (unsigned)msg_len, buf[0]); - if (writeFrame(buf, msg_len)) { - _sync_pending = true; /* confirmed on next request or lost on disconnect */ - } - } else { - LOG_DBG("CMD_SYNC_NEXT_MESSAGE: queue empty, sending NO_MORE_MSGS"); - uint8_t rsp[] = { PACKET_NO_MORE_MSGS }; - if (!writeFrame(rsp, sizeof(rsp))) { - return true; - } - - /* Initial sync is done — safe to apply deferred - * connection parameters now without disrupting - * channel/contact/message throughput. */ - zephcore_ble_conn_params_ready(); - - /* Full initial sync (contacts + messages) complete: stop - * suppressing v-contact notice prompts. Any notice queued during - * the window was just drained by this message-sync; if one raced in - * after the last peek, prompt for it now. */ - _vcontact_hold_msgwait = false; - _vcontact_hold_expiry = 0; - if (_offline_queue_count > 0) { - pushMsgWaiting(); - } - } - return true; - } - - case CMD_SET_ADVERT_NAME: - if (len >= 2) { - size_t nlen = len - 1; - if (nlen >= sizeof(prefs.node_name)) nlen = sizeof(prefs.node_name) - 1; - memcpy(prefs.node_name, &data[1], nlen); - prefs.node_name[nlen] = '\0'; - _store->savePrefs(prefs); - /* Push the new name to BLE so scanners see it without a reboot. */ - zephcore_ble_update_name(prefs.node_name); - /* v-contact name tracks the node name — update the app's copy. */ - vcontactPushAdvert(); - } - sendPacketOk(); - return true; - - case CMD_SET_RADIO_PARAMS: - if (len >= 11) { - uint32_t freq = get_le32(&data[1]); - uint32_t bw = get_le32(&data[5]); - uint8_t sf = data[9]; - uint8_t cr = data[10]; - uint8_t repeat = 0; - if (len > 11) { - repeat = data[11]; // v9+: client repeat flag - } - // If repeat requested, validate frequency against allowed bands - if (repeat && !isValidClientRepeatFreq(freq)) { - sendPacketError(ERR_ILLEGAL_ARG); - /* freq arrives in kHz, bw in Hz. Same limits as the USB - * CLI and the prefs loader — see NodePrefs.h. Accepting - * here what loadPrefs() later rejects is how a 2.4 GHz - * setting used to vanish on reboot. */ - } else if (freq >= (uint32_t)(ZC_RADIO_FREQ_MIN_MHZ * 1000.0f) && - freq <= (uint32_t)(ZC_RADIO_FREQ_MAX_MHZ * 1000.0f) && - bw >= (uint32_t)(ZC_RADIO_BW_MIN_KHZ * 1000.0f) && - bw <= (uint32_t)(ZC_RADIO_BW_MAX_KHZ * 1000.0f) && - sf >= 5 && sf <= 12 && - cr >= 5 && cr <= 8) { - /* Coding rate is deliberately not in this test: - * it changes airtime, not the channel or the - * per-SF/per-bandwidth detPeak base, so it does - * not invalidate the learned CAD offset. */ - bool preset_changed = - prefs.freq != (float)freq / 1000.0f || - prefs.bw != (float)bw / 1000.0f || - prefs.sf != sf; - prefs.freq = (float)freq / 1000.0f; - prefs.bw = (float)bw / 1000.0f; - prefs.sf = sf; - prefs.cr = cr; - prefs.client_repeat = repeat; - _store->savePrefs(prefs); - if (_radio_reconfig_cb) _radio_reconfig_cb(preset_changed); - LOG_INF("SET_RADIO_PARAMS: client_repeat=%d", repeat); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_RADIO_TX_POWER: - if (len >= 2) { - int8_t power = (int8_t)data[1]; - if (power >= -9 && power <= MAX_LORA_TX_POWER) { - prefs.tx_power_dbm = power; - _store->savePrefs(prefs); - if (_radio_reconfig_cb) _radio_reconfig_cb(false); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_ADVERT_LATLON: - if (len >= 9) { - int32_t lat = (int32_t)get_le32(&data[1]); - int32_t lon = (int32_t)get_le32(&data[5]); - // Arduino validation: lat ±90°, lon ±180° (in microdegrees) - if (lat >= -90000000 && lat <= 90000000 && - lon >= -180000000 && lon <= 180000000) { - prefs.node_lat = (double)lat / 1000000.0; - prefs.node_lon = (double)lon / 1000000.0; - _store->savePrefs(prefs); - LOG_INF("SET_ADVERT_LATLON: lat=%d lon=%d policy=%d", - lat, lon, prefs.advert_loc_policy); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_GET_DEVICE_TIME: { - uint8_t rsp[5]; - rsp[0] = PACKET_CURR_TIME; - put_le32(&rsp[1], (uint32_t)getRTCClock()->getCurrentTime()); - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_SET_DEVICE_TIME: - if (len >= 5) { - /* If we have GPS-synced time, ignore phone time sync attempts. - * GPS time is more accurate than phone time. Still return OK - * so the app doesn't keep retrying. */ - if (gps_has_time_sync()) { - LOG_DBG("Ignoring phone time sync - GPS time sync active"); - sendPacketOk(); - vcontactClockSynced(); /* GPS time counts as valid too */ - return true; - } - - uint32_t secs = get_le32(&data[1]); - uint32_t curr = getRTCClock()->getCurrentTime(); - // Arduino: only allow setting time forward (prevents time attacks) - if (secs >= curr) { - getRTCClock()->setCurrentTime(secs); - time_sync_report(TIME_SYNC_APP); - zephcore_rtc_save(secs); /* persist to hardware RTC */ - _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); - sendPacketOk(); - /* App just gave us wall time — activate the deferred - * v-contact and flush buffered notices. */ - vcontactClockSynced(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_DEVICE_QUERY: - if (len < 2) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - { - _app_target_ver = data[1]; // Which version of protocol does app understand - #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE __DATE__ - #endif - #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v0.0.0-dev" // real value injected by CMakeLists.txt - #endif - /* Wire format reserves 40 bytes for the board name. Require room - * for a null terminator within those 40 bytes so a phone parsing - * it as a C-string never reads past the field. */ - static_assert(sizeof(CONFIG_ZEPHCORE_BOARD_NAME) <= 40, - "CONFIG_ZEPHCORE_BOARD_NAME must fit in 40 bytes including null terminator"); - static const uint8_t fw_build[12] = FIRMWARE_BUILD_DATE; - static const uint8_t model[40] = CONFIG_ZEPHCORE_BOARD_NAME; - static const uint8_t version[20] = FIRMWARE_VERSION; // injected by CMakeLists.txt - uint8_t rsp[82]; - rsp[0] = PACKET_DEVICE_INFO; - /* v14 = CMD_RUN_CLI_COMMAND / PACKET_CLI_REPLY, plus TXT_TYPE_CLI_COMMAND - * accepted by CMD_SEND_TXT_MSG and by the repeater / room-server admin - * CLI. Deliberately NOT the whole of upstream's v14: a companion here - * never *executes* an over-the-air TXT_TYPE_CLI_COMMAND, so contact - * flag 0x10 (remote-CLI-allowed) is stored and ignored. See - * devdocs/UPSTREAM_TRACKER.md for why. */ - rsp[1] = 14; // FIRMWARE_VER_CODE - rsp[2] = (MAX_CONTACTS / 2 > 255) ? 255 : (MAX_CONTACTS / 2); // protocol byte, app multiplies by 2 - rsp[3] = MAX_GROUP_CHANNELS; - put_le32(&rsp[4], prefs.ble_pin ? prefs.ble_pin : 123456); // BLE PIN - memcpy(&rsp[8], fw_build, 12); - memcpy(&rsp[20], model, 40); - memcpy(&rsp[60], version, 20); - rsp[80] = prefs.client_repeat; // v9+: offgrid mode state - rsp[81] = prefs.path_hash_mode; // v10+: path hash mode - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_GET_TUNING_PARAMS: { - uint8_t rsp[9]; - rsp[0] = PACKET_TUNING_PARAMS; - put_le32(&rsp[1], (uint32_t)(prefs.rx_delay_base * 1000)); - put_le32(&rsp[5], (uint32_t)(prefs.airtime_factor * 1000)); - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_SET_TUNING_PARAMS: - if (len < 9) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - prefs.rx_delay_base = (float)get_le32(&data[1]) / 1000.0f; - prefs.airtime_factor = (float)get_le32(&data[5]) / 1000.0f; - _store->savePrefs(prefs); - sendPacketOk(); - return true; - - case CMD_EXPORT_PRIVATE_KEY: { - // Response: [PACKET_PRIVATE_KEY=0x0E][64 bytes: 32 private + 32 public] - uint8_t rsp[65]; - rsp[0] = PACKET_PRIVATE_KEY; - self_id.writeTo(&rsp[1], 64); - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_FACTORY_RESET: - // Arduino: requires "reset" magic string for safety - if (len >= 6 && memcmp(&data[1], "reset", 5) == 0) { - LOG_INF("Factory reset requested"); - _store->factoryReset(); - sendPacketOk(); - // Reboot to regenerate identity - sys_reboot(SYS_REBOOT_COLD); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_REBOOT: - if (len >= 7 && memcmp(&data[1], "reboot", 6) == 0) { - LOG_INF("Reboot requested"); - /* Flush any pending lazy writes before reboot */ - flushDirtyContacts(); - flushDirtyChannels(); - sendPacketOk(); - sys_reboot(SYS_REBOOT_COLD); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_OTHER_PARAMS: - if (len >= 2) { - prefs.manual_add_contacts = data[1]; - if (len >= 3) { - // Telemetry modes: (env << 4) | (loc << 2) | base - prefs.telemetry_mode_base = data[2] & 0x03; - prefs.telemetry_mode_loc = (data[2] >> 2) & 0x03; - prefs.telemetry_mode_env = (data[2] >> 4) & 0x03; - if (len >= 4) { - prefs.advert_loc_policy = data[3]; - LOG_INF("SET_OTHER_PARAMS: advert_loc_policy=%d", prefs.advert_loc_policy); - if (len >= 5) { - prefs.multi_acks = data[4]; - } - } - } - _store->savePrefs(prefs); - } - sendPacketOk(); - return true; - - case CMD_SET_DEVICE_PIN: - if (len >= 5) { - uint32_t pin; - memcpy(&pin, &data[1], 4); - if (pin == 0 || (pin >= 100000 && pin <= 999999)) { - prefs.ble_pin = pin; - _store->savePrefs(prefs); - if (_pin_change_cb) _pin_change_cb(pin); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SIGN_START: - // Free any previous signing state - if (_sign_data) { - delete[] _sign_data; - _sign_data = nullptr; - } - _sign_data_len = 0; - _sign_data_capacity = 0; - // Allocate new buffer - _sign_data = new uint8_t[MAX_SIGN_DATA_LEN]; - if (_sign_data) { - _sign_data_capacity = MAX_SIGN_DATA_LEN; - // Response: [code][reserved][4-byte max_len] = 6 bytes (matches Arduino) - uint8_t rsp[6]; - rsp[0] = PACKET_SIGN_START; - rsp[1] = 0; // reserved - uint32_t max_len = MAX_SIGN_DATA_LEN; - put_le32(&rsp[2], max_len); - writeFrame(rsp, sizeof(rsp)); - } else { - sendPacketError(ERR_BAD_STATE); - } - return true; - - case CMD_SIGN_DATA: - if (_sign_data && len > 1) { - size_t chunk_len = len - 1; - if (_sign_data_len + chunk_len <= _sign_data_capacity) { - memcpy(&_sign_data[_sign_data_len], &data[1], chunk_len); - _sign_data_len += chunk_len; - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_BAD_STATE); - } - return true; - - case CMD_SIGN_FINISH: - if (_sign_data && _sign_data_len > 0) { - uint8_t signature[64]; - // Sign the data using Ed25519 via LocalIdentity method - self_id.sign(signature, _sign_data, _sign_data_len); - uint8_t rsp[1 + 64]; - rsp[0] = PACKET_SIGNATURE; - memcpy(&rsp[1], signature, 64); - writeFrame(rsp, sizeof(rsp)); - // Clean up - delete[] _sign_data; - _sign_data = nullptr; - _sign_data_len = 0; - _sign_data_capacity = 0; - } else { - sendPacketError(ERR_BAD_STATE); - } - return true; - - case CMD_SEND_TRACE_PATH: - /* Trace path: [cmd][4-byte tag][4-byte auth][flags][path...] */ - if (len > 10 && (len - 10) < MAX_PACKET_PAYLOAD - 5) { - uint8_t path_len = len - 10; - uint8_t flags = data[9]; - uint8_t path_sz = flags & 0x03; /* Path hash size encoding */ - /* Validate path length is multiple of hash size */ - if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { - sendPacketError(ERR_ILLEGAL_ARG); - } else { - uint32_t tag = get_le32(&data[1]); - uint32_t auth_code = get_le32(&data[5]); - mesh::Packet *trace = createTrace(tag, auth_code, flags); - if (trace) { - sendDirect(trace, &data[10], path_len); - - uint32_t airtime = _radio->getEstAirtimeFor(trace->payload_len + trace->path_len + 2); - uint32_t est_timeout = calcDirectTimeoutMillisFor(airtime, path_len >> path_sz); - - uint8_t rsp[10]; - rsp[0] = PACKET_SENT; - rsp[1] = 0; /* Not flood */ - put_le32(&rsp[2], tag); - put_le32(&rsp[6], est_timeout); - writeFrame(rsp, sizeof(rsp)); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } - } else if (len >= 9) { - /* Legacy: zero-hop trace (backwards compat) */ - uint32_t tag = get_le32(&data[1]); - uint32_t auth_code = get_le32(&data[5]); - mesh::Packet *trace = createTrace(tag, auth_code); - if (trace) { - sendZeroHop(trace); - sendPacketOk(); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketOk(); /* Backwards compat */ - } - return true; - - case CMD_IMPORT_PRIVATE_KEY: - /* Import private key: [cmd][64 bytes prv_key] */ - if (len >= 1 + PRV_KEY_SIZE) { - if (!mesh::LocalIdentity::validatePrivateKey(&data[1])) { - sendPacketError(ERR_ILLEGAL_ARG); /* invalid key */ - } else { - mesh::LocalIdentity new_identity; - new_identity.readFrom(&data[1], PRV_KEY_SIZE); - if (_store->saveMainIdentity(new_identity)) { - /* The v-contact key is derived from our identity, so it - * changes with the new key. Tell the connected app to drop - * the old v-contact (old key), re-derive, then re-advertise - * the new one — otherwise the app's cached loopback contact - * points at a key we no longer recognise (messaging + its - * advert-path query break until a reboot re-syncs). Order - * matters: delete uses the CURRENT (old) key. */ - bool vc_was_enabled = isVContactEnabled(); - if (vc_was_enabled) vcontactPushDeleted(); - self_id = new_identity; - deriveVContactKey(); - /* New identity → old advert timestamp is meaningless. Clear - * it so vcontactPushAdvert() re-activates cleanly: with a - * valid clock it stamps + emits now; without one it defers - * and vcontactClockSynced() (the lastmod==0 path) emits at the - * next time-sync instead of only after a reboot. */ - _vcontact_lastmod = 0; - /* Different key → different contact in the app; the old - * favourite/telemetry flags don't carry over. Persist - * before the push so a reboot can't resurrect them. */ - if (prefs.v_contact_flags != 0) { - prefs.v_contact_flags = 0; - _store->savePrefs(prefs); - } - if (vc_was_enabled) vcontactPushAdvert(); - /* Reload contacts to invalidate ECDH shared secrets */ - resetContacts(); - _store->loadContacts(this); - sendPacketOk(); - } else { - sendPacketError(ERR_BAD_STATE); - } - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_RAW_DATA: - /* Raw data packet: [cmd][path_len][path...][payload...] - * - * path_len is the encoded 2-bit (hash_size - 1) + 6-bit hop count, not - * a byte count, so it has to be decoded rather than added to an offset. - * Treating it as a length rejected every path with a hash size above 1: - * hash_size 2 sets bit 6 (path_len 0x42 for two hops read as 66 bytes, - * failing the length check) and hash_size 3 sets bit 7, which used to - * read back as a negative int8_t. */ - if (len >= 6) { /* min: cmd + path_len + 4 byte payload */ - size_t i = 1; - uint8_t path_len = data[i++]; - if (mesh::Packet::isValidPathLen(path_len)) { - uint8_t path[MAX_PATH_SIZE]; - /* Untrusted phone frame: bound the source at the bytes that - * actually remain, so a crafted path_len cannot over-read. */ - size_t path_bytes = mesh::Packet::writePath(path, &data[i], len - i, path_len); - if (path_bytes == 0 && (path_len & 63) != 0) { - /* Zero bytes written with a non-zero hop count (low 6 - * bits): the decoded path runs past the end of the frame. */ - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - i += path_bytes; - if (i + 4 > len) { /* min payload 4 bytes */ - sendPacketError(ERR_ILLEGAL_ARG); - } else { - mesh::Packet *pkt = createRawData(&data[i], len - i); - if (pkt) { - sendDirect(pkt, path, path_len); - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } - } else { - /* Flood mode not supported */ - sendPacketError(ERR_UNSUPPORTED); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_LOGIN: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - size_t pw_len = len - 1 - PUB_KEY_SIZE; - char pw_buf[64]; - if (pw_len >= sizeof(pw_buf)) pw_len = sizeof(pw_buf) - 1; - memcpy(pw_buf, &data[1 + PUB_KEY_SIZE], pw_len); - pw_buf[pw_len] = '\0'; - const char *password = pw_buf; - if (contact) { - uint32_t est_timeout; - LOG_DBG("CMD_SEND_LOGIN: sending to contact, path_len=%d", contact->out_path_len); - int result = sendLogin(*contact, password, est_timeout); - LOG_DBG("CMD_SEND_LOGIN: sendLogin returned %d, est_timeout=%u", result, est_timeout); - if (result != MSG_SEND_FAILED) { - /* _pending_login was set by onLoginSent (BaseChatMesh::sendLogin hook); - * clear the other pending fields manually — clearPendingReqs() would wipe it. */ - _pending_status = _pending_telemetry = _pending_discovery = _pending_req = 0; - LOG_DBG("CMD_SEND_LOGIN: _pending_login set to %08x", _pending_login); - sendPacketSent(result, _pending_login, est_timeout); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_STATUS_REQ: - if (len >= 1 + PUB_KEY_SIZE) { - ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (contact) { - uint32_t tag, est_timeout; - int result = sendRequest(*contact, REQ_TYPE_GET_STATUS, tag, est_timeout); - if (result != MSG_SEND_FAILED) { - clearPendingReqs(); - memcpy(&_pending_status, contact->id.pub_key, 4); // legacy matching scheme - sendPacketSent(result, tag, est_timeout); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_HAS_CONNECTION: - if (len >= 1 + PUB_KEY_SIZE) { - if (hasConnectionTo(&data[1])) { - sendPacketOk(); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_LOGOUT: - if (len >= 1 + PUB_KEY_SIZE) { - stopConnection(&data[1]); - } - sendPacketOk(); - return true; - - case CMD_SEND_TELEMETRY_REQ: - if (len == 4) { - // Self-telemetry request: return battery, GPS, and environment data - // Format: Cayenne LPP: [channel][type][data...] - // Response: [PUSH_CODE_TELEMETRY_RESPONSE][reserved][6-byte pubkey][telemetry_data] - // Worst-case size tracks POWER_MAX_CHANNELS so a future bump can't - // silently overflow this stack buffer. With current value 4: - // header(8) + batt(4) + gps(11) + env(temp4+hum3+press4+lum4=15) - // + power(POWER_MAX_CHANNELS * 12 = 48) + 8 byte safety pad = 94. - uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; - int i = 0; - rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; - rsp[i++] = 0; // reserved - memcpy(&rsp[i], self_id.pub_key, 6); - i += 6; // pubkey prefix - - // Self-telemetry is unconditional (all permission bits set). - i += appendSelfTelemetry(&rsp[i], TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT); - writeFrame(rsp, i); - } else if (len >= 4 + PUB_KEY_SIZE) { - // Contact telemetry request: [cmd][3 reserved bytes][32-byte pubkey] - ContactInfo *contact = lookupContactByPubKey(&data[4], PUB_KEY_SIZE); - if (contact) { - uint32_t tag, est_timeout; - int result = sendRequest(*contact, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout); - if (result != MSG_SEND_FAILED) { - clearPendingReqs(); - _pending_telemetry = tag; - sendPacketSent(result, tag, est_timeout); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_BINARY_REQ: - // Format: [cmd][32-byte pubkey][req_data...] - if (len >= 1 + PUB_KEY_SIZE + 1) { // Need at least cmd + pubkey + 1 byte req_data - ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (contact) { - uint32_t tag, est_timeout; - // req_data starts after pubkey - const uint8_t *req_data = &data[1 + PUB_KEY_SIZE]; - size_t req_len = len - (1 + PUB_KEY_SIZE); - int result = sendRequest(*contact, req_data, req_len, tag, est_timeout); - if (result != MSG_SEND_FAILED) { - clearPendingReqs(); - _pending_req = tag; - sendPacketSent(result, tag, est_timeout); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_PATH_DISCOVERY: - if (len >= 2 + PUB_KEY_SIZE && data[1] == 0) { - // Path Discovery is a special flood + Telemetry request - ContactInfo *contact = lookupContactByPubKey(&data[2], PUB_KEY_SIZE); - if (contact) { - uint32_t tag, est_timeout; - // Build telemetry request with only BASE permissions - uint8_t req_data[9]; - req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; - req_data[1] = ~(TELEM_PERM_BASE); // inverse permissions mask - memset(&req_data[2], 0, 3); // reserved - getRNG()->random(&req_data[5], 4); // random to make packet-hash unique - - // Temporarily force flood - auto save = contact->out_path_len; - contact->out_path_len = OUT_PATH_UNKNOWN; - int result = sendRequest(*contact, req_data, sizeof(req_data), tag, est_timeout); - contact->out_path_len = save; - - if (result != MSG_SEND_FAILED) { - clearPendingReqs(); - _pending_discovery = tag; - sendPacketSent(result, tag, est_timeout); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_GET_ADVERT_PATH: - // Format: [cmd][reserved][7-byte pubkey prefix] - if (len >= 2 + 7) { - const AdvertPath *ap = findAdvertPath(&data[2], 7); // pubkey at offset 2 - if (ap) { - // Response: [code][4-byte timestamp][path_len][path...] - uint8_t rsp[6 + MAX_PATH_SIZE]; - size_t i = 0; - rsp[i++] = PACKET_ADVERT_PATH; - put_le32(&rsp[i], ap->recv_timestamp); i += 4; - rsp[i++] = ap->path_len; - /* Trusted source: AdvertPath::path is MAX_PATH_SIZE-sized. */ - i += mesh::Packet::writePath(&rsp[i], ap->path, MAX_PATH_SIZE, ap->path_len); - writeFrame(rsp, i); - } else { - sendPacketError(ERR_NOT_FOUND); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_GET_CUSTOM_VARS: { - // Return GPS and sensor settings as comma-separated key:value pairs - // Format: [PACKET_CUSTOM_VARS][key1:val1,key2:val2,...] - uint8_t rsp[64]; - char *dp = (char *)&rsp[1]; - char *const rsp_end = (char *)&rsp[sizeof(rsp)]; - rsp[0] = PACKET_CUSTOM_VARS; - - // snprintf returns the would-be length, NOT bytes actually written. - // We must check truncation and only advance dp on real progress — - // otherwise dp can outrun the initialized portion of rsp and - // writeFrame(rsp, dp-rsp) leaks adjacent stack to the phone. - - bool first = true; - if (gps_is_available()) { - size_t remaining = (size_t)(rsp_end - dp); - int n = snprintf(dp, remaining, "gps:%d", gps_is_enabled() ? 1 : 0); - if (n > 0 && (size_t)n < remaining) { - dp += n; - first = false; - } - } - uint32_t gps_interval = gps_get_poll_interval_sec(); - if (gps_interval > 0) { - size_t remaining = (size_t)(rsp_end - dp); - // Reserve 1 byte for the leading ',' if needed - if (!first && remaining > 0) { - *dp++ = ','; - remaining--; - } - int n = snprintf(dp, remaining, "gps_interval:%u", (unsigned)gps_interval); - if (n > 0 && (size_t)n < remaining) { - dp += n; - first = false; - } - // If snprintf would have truncated, dp stays put — writeFrame - // sends only what we successfully wrote. - } - { - size_t remaining = (size_t)(rsp_end - dp); - if (!first && remaining > 0) { - *dp++ = ','; - remaining--; - } - int n = snprintf(dp, remaining, "meshtimesync:%d", - prefs.meshtimesync ? 1 : 0); - if (n > 0 && (size_t)n < remaining) { - dp += n; - } - } - // Note: Environment sensors are auto-detected, no settings needed - - writeFrame(rsp, (size_t)(dp - (char *)rsp)); - return true; - } - - case CMD_SET_CUSTOM_VAR: - // Format: [cmd][key:value] - if (len >= 4) { - char buf[64]; - size_t copy_len = (len - 1 < sizeof(buf) - 1) ? len - 1 : sizeof(buf) - 1; - memcpy(buf, &data[1], copy_len); - buf[copy_len] = '\0'; - - char *sep = strchr(buf, ':'); - if (sep) { - *sep = '\0'; - char *key = buf; - char *val = sep + 1; - - if (strcmp(key, "gps") == 0) { - bool enable = (val[0] == '1'); - gps_enable(enable); - /* Persist GPS state across reboots */ - prefs.gps_enabled = enable ? 1 : 0; - _store->savePrefs(prefs); - sendPacketOk(); - } else if (strcmp(key, "gps_interval") == 0) { - uint32_t interval = (uint32_t)atoi(val); - if (interval > 0 && interval <= 86400) { - gps_set_poll_interval_sec(interval); - /* Persist GPS interval across reboots */ - prefs.gps_interval = interval; - _store->savePrefs(prefs); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else if (strcmp(key, "meshtimesync") == 0) { - prefs.meshtimesync = (val[0] == '1') ? 1 : 0; - _store->savePrefs(prefs); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_FLOOD_SCOPE_KEY: - /* Set send scope mode (Arduino MeshCore PR #2492, ver 12+): - * [cmd][0][16-byte key] = explicit scoped override - * [cmd][0] = clear scope override (use default_scope from prefs) - * [cmd][1] = explicit unscoped (sticky flag, bypasses default_scope) - */ - if (len >= 2 && data[1] == 0) { - if (len >= 2 + 16) { - memcpy(_send_scope.key, &data[2], sizeof(_send_scope.key)); - } else { - memset(_send_scope.key, 0, sizeof(_send_scope.key)); - } - _send_scope_force_unscoped = false; - sendPacketOk(); - } else if (len == 2 && data[1] == 1) { - _send_scope_force_unscoped = true; - memset(_send_scope.key, 0, sizeof(_send_scope.key)); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_DEFAULT_FLOOD_SCOPE: - /* Set default flood scope: [cmd][name:31][key:16] or [cmd] alone (clear) */ - if (len >= 1 + 31 + 16) { - int n = strnlen((const char *)&data[1], 31); - if (n > 0 && n < 31) { - memset(prefs.default_scope_name, 0, sizeof(prefs.default_scope_name)); - memcpy(prefs.default_scope_name, &data[1], n); - memcpy(prefs.default_scope_key, &data[1 + 31], 16); - _store->savePrefs(prefs); - sendPacketOk(); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - memset(prefs.default_scope_name, 0, sizeof(prefs.default_scope_name)); - memset(prefs.default_scope_key, 0, sizeof(prefs.default_scope_key)); - _store->savePrefs(prefs); - sendPacketOk(); - } - return true; - - case CMD_GET_DEFAULT_FLOOD_SCOPE: { - uint8_t rsp[1 + 31 + 16]; - rsp[0] = PACKET_DEFAULT_FLOOD_SCOPE; - if (strlen(prefs.default_scope_name) > 0) { - memcpy(&rsp[1], prefs.default_scope_name, 31); - memcpy(&rsp[1 + 31], prefs.default_scope_key, 16); - writeFrame(rsp, sizeof(rsp)); - } else { - writeFrame(rsp, 1); /* no name or key means null */ - } - return true; - } - - case CMD_SEND_CONTROL_DATA: - /* Control data: [cmd][flags | 0x80][data...] */ - if (len >= 2 && (data[1] & 0x80) != 0) { - mesh::Packet *pkt = createControlData(&data[1], len - 1); - if (pkt) { - sendZeroHop(pkt); - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SET_AUTOADD_CONFIG: - if (len >= 2) { - prefs.autoadd_config = data[1]; - if (len >= 3) { - prefs.autoadd_max_hops = (data[2] > 64) ? 64 : data[2]; - } - LOG_INF("SET_AUTOADD_CONFIG: autoadd_config=0x%02x max_hops=%u", - prefs.autoadd_config, prefs.autoadd_max_hops); - _store->savePrefs(prefs); - } - sendPacketOk(); - return true; - - case CMD_GET_AUTOADD_CONFIG: { - uint8_t rsp[3]; - rsp[0] = PACKET_AUTOADD_CONFIG; - rsp[1] = prefs.autoadd_config; - rsp[2] = prefs.autoadd_max_hops; - writeFrame(rsp, sizeof(rsp)); - return true; - } - - case CMD_GET_ALLOWED_REPEAT_FREQ: { - uint8_t rsp[1 + sizeof(repeat_freq_ranges)]; - int i = 0; - rsp[i++] = PACKET_ALLOWED_REPEAT_FREQ; - for (size_t k = 0; k < sizeof(repeat_freq_ranges) / sizeof(repeat_freq_ranges[0]); k++) { - put_le32(&rsp[i], repeat_freq_ranges[k].lower_freq); i += 4; - put_le32(&rsp[i], repeat_freq_ranges[k].upper_freq); i += 4; - } - writeFrame(rsp, i); - return true; - } - - case CMD_SET_PATH_HASH_MODE: - if (len >= 3 && data[1] == 0) { - if (data[2] >= 3) { - sendPacketError(ERR_ILLEGAL_ARG); - } else { - prefs.path_hash_mode = data[2]; - _store->savePrefs(prefs); - sendPacketOk(); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_CHANNEL_DATA: { - /* Minimum frame: cmd(1) + channel_idx(1) + path_len(1) + path(0..) + - * data_type(2) + payload(0..) = 5 bytes when path is empty. */ - if (len < 5) { - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - int i = 1; - uint8_t channel_idx = data[i++]; - uint8_t path_len = data[i++]; - - if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { - LOG_WRN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - - /* Compute decoded path byte count and ensure source frame has room - * for path + data_type. (path_len is the 6-bit hash_count + 2-bit - * hash_size encoding; writePath itself will reject if src_len is - * too small, but failing here also rejects truncated data_type.) */ - uint8_t hash_count = path_len & 63; - uint8_t hash_size = (path_len >> 6) + 1; - size_t path_bytes = (path_len == OUT_PATH_UNKNOWN) ? 0 - : (size_t)hash_count * hash_size; - if ((size_t)i + path_bytes + 2 > len) { - LOG_WRN("CMD_SEND_CHANNEL_DATA short frame: len=%u need >=%u", - (unsigned)len, (unsigned)(i + path_bytes + 2)); - sendPacketError(ERR_ILLEGAL_ARG); - return true; - } - - uint8_t path[MAX_PATH_SIZE]; - if (path_len != OUT_PATH_UNKNOWN) { - /* src_len = remaining bytes from data[i] onward. */ - i += mesh::Packet::writePath(path, &data[i], len - i, path_len); - } - - uint16_t data_type = ((uint16_t)data[i]) | (((uint16_t)data[i + 1]) << 8); - i += 2; - const uint8_t *payload = &data[i]; - int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; - - ChannelDetails channel; - if (!getChannel(channel_idx, channel)) { - sendPacketError(ERR_NOT_FOUND); - } else if (data_type == DATA_TYPE_RESERVED) { - sendPacketError(ERR_ILLEGAL_ARG); - } else if (payload_len > MAX_GROUP_DATA_LENGTH) { - /* Radio-side bound (165), NOT the host-frame bound (167). These - * disagree, and sendGroupData() enforces the smaller one, so gating - * here on MAX_CHANNEL_DATA_LENGTH let a 166/167-byte payload pass the - * frame check, fail the radio check, and come back to the app as - * ERR_TABLE_FULL — the "outbound queue full, retry later" code — for a - * send that can never succeed at any queue depth. Upstream 0a82fcd2. */ - LOG_WRN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); - sendPacketError(ERR_ILLEGAL_ARG); - } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { - sendPacketOk(); - } else { - sendPacketError(ERR_TABLE_FULL); - } - return true; - } - - case CMD_SEND_ANON_REQ: - if (len >= 1 + PUB_KEY_SIZE + 1) { - ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - if (contact == nullptr) { - /* FIRMWARE_VER_CODE 13+: allow requests to a non-contact pubkey by - * creating a transient "anon" contact (ADV_TYPE_NONE). These are never - * persisted or synced to the app; re-lookup to get the stable slot. */ - ContactInfo anon{}; - memcpy(anon.id.pub_key, &data[1], PUB_KEY_SIZE); - anon.out_path_len = 0; // zero-hop direct by default - anon.type = ADV_TYPE_NONE; // transient/unknown - anon.lastmod = getRTCClock()->getCurrentTime(); // so slot recycling is LRU, not always slot 0 - if (addContact(anon)) { - contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); - } - } - if (contact) { - uint32_t tag, est_timeout; - int result = sendAnonReq(*contact, &data[1 + PUB_KEY_SIZE], len - 1 - PUB_KEY_SIZE, tag, est_timeout); - if (result != MSG_SEND_FAILED) { - clearPendingReqs(); - _pending_req = tag; - sendPacketSent(result, tag, est_timeout); - } else { - sendPacketError(ERR_BAD_STATE); - } - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_RUN_CLI_COMMAND: - /* Runs a CLI line on behalf of the connected app. This is the same - * local CLI the USB text sideband and the v-contact chat already reach, - * over the same already-paired transport, so it adds no reach that an - * app connected to this node did not already have — it is executed with - * sender_timestamp 0 (local) for exactly that reason. */ - if (len >= 2) { - if (_cli_exec_cb == nullptr) { - sendPacketError(ERR_UNSUPPORTED); - return true; - } - - /* `data` is const, so the line has to be copied out to be - * null-terminated. A command can never exceed one frame. */ - char line[MAX_FRAME_SIZE]; - size_t cmd_len = len - 1; - if (cmd_len > sizeof(line) - 1) cmd_len = sizeof(line) - 1; - memcpy(line, &data[1], cmd_len); - line[cmd_len] = '\0'; - - /* Reply is built in place behind the frame type byte; the callback - * contract requires COMPANION_CLI_REPLY_SIZE of room. */ - uint8_t rsp[1 + COMPANION_CLI_REPLY_SIZE]; - char *reply = (char *)&rsp[1]; - rsp[0] = PACKET_CLI_REPLY; - reply[0] = '\0'; - _cli_exec_cb(line, reply); - if (reply[0] == '\0') { - strcpy(reply, "Unknown command"); - } - - /* A CommonCLI reply can be longer than one companion frame and the - * app protocol has no continuation for this response, so truncate - * rather than drop. */ - size_t rlen = strlen(reply); - if (rlen > MAX_FRAME_SIZE - 1) rlen = MAX_FRAME_SIZE - 1; - writeFrame(rsp, rlen + 1); - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - - case CMD_SEND_RAW_PACKET: - if (len >= 4) { - mesh::Packet *pkt = obtainNewPacket(); - if (pkt) { - uint8_t priority = data[1]; - if (tryParsePacket(pkt, &data[2], len - 2)) { - sendPacket(pkt, priority, 0); - sendPacketOk(); - } else { - releasePacket(pkt); - sendPacketError(ERR_ILLEGAL_ARG); - } - } else { - sendPacketError(ERR_TABLE_FULL); - } - } else { - sendPacketError(ERR_ILLEGAL_ARG); - } - return true; - + case CMD_DEVICE_QUERY: return handleCmdDeviceQuery(data, len); + case CMD_APP_START: return handleCmdAppStart(data, len); + case CMD_RUN_CLI_COMMAND: return handleCmdRunCliCommand(data, len); + case CMD_SEND_TXT_MSG: return handleCmdSendTxtMsg(data, len); + case CMD_SEND_CHANNEL_TXT_MSG: return handleCmdSendChannelTxtMsg(data, len); + case CMD_SEND_CHANNEL_DATA: return handleCmdSendChannelData(data, len); + case CMD_GET_CONTACTS: return handleCmdGetContacts(data, len); + case CMD_SET_ADVERT_NAME: return handleCmdSetAdvertName(data, len); + case CMD_SET_ADVERT_LATLON: return handleCmdSetAdvertLatlon(data, len); + case CMD_GET_DEVICE_TIME: return handleCmdGetDeviceTime(data, len); + case CMD_SET_DEVICE_TIME: return handleCmdSetDeviceTime(data, len); + case CMD_SEND_SELF_ADVERT: return handleCmdSendSelfAdvert(data, len); + case CMD_RESET_PATH: return handleCmdResetPath(data, len); + case CMD_ADD_UPDATE_CONTACT: return handleCmdAddUpdateContact(data, len); + case CMD_REMOVE_CONTACT: return handleCmdRemoveContact(data, len); + case CMD_SHARE_CONTACT: return handleCmdShareContact(data, len); + case CMD_GET_CONTACT_BY_KEY: return handleCmdGetContactByKey(data, len); + case CMD_EXPORT_CONTACT: return handleCmdExportContact(data, len); + case CMD_IMPORT_CONTACT: return handleCmdImportContact(data, len); + case CMD_SYNC_NEXT_MESSAGE: return handleCmdSyncNextMessage(data, len); + case CMD_SET_RADIO_PARAMS: return handleCmdSetRadioParams(data, len); + case CMD_SET_RADIO_TX_POWER: return handleCmdSetRadioTxPower(data, len); + case CMD_SET_TUNING_PARAMS: return handleCmdSetTuningParams(data, len); + case CMD_GET_TUNING_PARAMS: return handleCmdGetTuningParams(data, len); + case CMD_SET_OTHER_PARAMS: return handleCmdSetOtherParams(data, len); + case CMD_SET_PATH_HASH_MODE: return handleCmdSetPathHashMode(data, len); + case CMD_REBOOT: return handleCmdReboot(data, len); + case CMD_GET_BATT_AND_STORAGE: return handleCmdGetBattAndStorage(data, len); + case CMD_EXPORT_PRIVATE_KEY: return handleCmdExportPrivateKey(data, len); + case CMD_IMPORT_PRIVATE_KEY: return handleCmdImportPrivateKey(data, len); + case CMD_SEND_RAW_DATA: return handleCmdSendRawData(data, len); + case CMD_SEND_LOGIN: return handleCmdSendLogin(data, len); + case CMD_SEND_ANON_REQ: return handleCmdSendAnonReq(data, len); + case CMD_SEND_STATUS_REQ: return handleCmdSendStatusReq(data, len); + case CMD_SEND_PATH_DISCOVERY_REQ: return handleCmdSendPathDiscoveryReq(data, len); + case CMD_SEND_TELEMETRY_REQ: return handleCmdSendTelemetryReq(data, len); + case CMD_SEND_BINARY_REQ: return handleCmdSendBinaryReq(data, len); + case CMD_HAS_CONNECTION: return handleCmdHasConnection(data, len); + case CMD_LOGOUT: return handleCmdLogout(data, len); + case CMD_GET_CHANNEL: return handleCmdGetChannel(data, len); + case CMD_SET_CHANNEL: return handleCmdSetChannel(data, len); + case CMD_SIGN_START: return handleCmdSignStart(data, len); + case CMD_SIGN_DATA: return handleCmdSignData(data, len); + case CMD_SIGN_FINISH: return handleCmdSignFinish(data, len); + case CMD_SEND_TRACE_PATH: return handleCmdSendTracePath(data, len); + case CMD_SET_DEVICE_PIN: return handleCmdSetDevicePin(data, len); + case CMD_GET_CUSTOM_VARS: return handleCmdGetCustomVars(data, len); + case CMD_SET_CUSTOM_VAR: return handleCmdSetCustomVar(data, len); + case CMD_GET_ADVERT_PATH: return handleCmdGetAdvertPath(data, len); + case CMD_GET_STATS: return handleCmdGetStats(data, len); + case CMD_FACTORY_RESET: return handleCmdFactoryReset(data, len); + case CMD_SET_FLOOD_SCOPE_KEY: return handleCmdSetFloodScopeKey(data, len); + case CMD_SET_DEFAULT_FLOOD_SCOPE: return handleCmdSetDefaultFloodScope(data, len); + case CMD_GET_DEFAULT_FLOOD_SCOPE: return handleCmdGetDefaultFloodScope(data, len); + case CMD_SEND_CONTROL_DATA: return handleCmdSendControlData(data, len); + case CMD_SET_AUTOADD_CONFIG: return handleCmdSetAutoaddConfig(data, len); + case CMD_GET_AUTOADD_CONFIG: return handleCmdGetAutoaddConfig(data, len); + case CMD_GET_ALLOWED_REPEAT_FREQ: return handleCmdGetAllowedRepeatFreq(data, len); + case CMD_SEND_RAW_PACKET: return handleCmdSendRawPacket(data, len); default: break; } return false; } + +bool CompanionMesh::handleCmdDeviceQuery(const uint8_t *data, size_t len) +{ + if (len < 2) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + { + _app_target_ver = data[1]; // Which version of protocol does app understand + #ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE __DATE__ + #endif + #ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v0.0.0-dev" // real value injected by CMakeLists.txt + #endif + /* Wire format reserves 40 bytes for the board name. Require room + * for a null terminator within those 40 bytes so a phone parsing + * it as a C-string never reads past the field. */ + static_assert(sizeof(CONFIG_ZEPHCORE_BOARD_NAME) <= 40, + "CONFIG_ZEPHCORE_BOARD_NAME must fit in 40 bytes including null terminator"); + static const uint8_t fw_build[12] = FIRMWARE_BUILD_DATE; + static const uint8_t model[40] = CONFIG_ZEPHCORE_BOARD_NAME; + static const uint8_t version[20] = FIRMWARE_VERSION; // injected by CMakeLists.txt + uint8_t rsp[82]; + rsp[0] = PACKET_DEVICE_INFO; + /* v14 = CMD_RUN_CLI_COMMAND / PACKET_CLI_REPLY, and TXT_TYPE_CLI_COMMAND + * both sent (CMD_SEND_TXT_MSG) and executed from a contact with flag + * 0x10 (onCLICommandRecv), as upstream. */ + rsp[1] = 14; // FIRMWARE_VER_CODE + rsp[2] = (MAX_CONTACTS / 2 > 255) ? 255 : (MAX_CONTACTS / 2); // protocol byte, app multiplies by 2 + rsp[3] = MAX_GROUP_CHANNELS; + put_le32(&rsp[4], prefs.ble_pin ? prefs.ble_pin : 123456); // BLE PIN + memcpy(&rsp[8], fw_build, 12); + memcpy(&rsp[20], model, 40); + memcpy(&rsp[60], version, 20); + rsp[80] = prefs.client_repeat; // v9+: offgrid mode state + rsp[81] = prefs.path_hash_mode; // v10+: path hash mode + writeFrame(rsp, sizeof(rsp)); + return true; + } +} + +bool CompanionMesh::handleCmdAppStart(const uint8_t *data, size_t len) +{ + if (len < 8) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + LOG_INF("CMD_APP_START: session reset, %d queued", _offline_queue_count); + // Reset per-session state for a fresh app session. BLE/USB also run + // this cleanup on disconnect, but the serial transport has no + // disconnect event, so APP_START is its authoritative session-reset + // point: drop a stale contact iteration (a leftover PACKET_CONTACT_END + // would confuse the new session's sync state machine), a half-finished + // message sync, and free an abandoned Ed25519 sign buffer (else an 8KB + // leak if the previous session dropped mid-CMD_SIGN). Idempotent — all + // no-ops when the state is already clean. + _contact_iter_active = false; + cancelSyncPending(); + cleanupSignState(); + /* Drop a deferred v-contact confirm from the previous session: its ack + * matches an outbound message the new session never sent, so delivering + * it here would push an unmatched SEND_CONFIRMED into a fresh app. */ + _vcontact_confirm_ack = 0; + k_work_cancel_delayable(&_vcontact_confirm_work.work); + + /* New session: suppress v-contact notice MSG_WAITING until the initial + * sync (contacts + messages) completes at PACKET_NO_MORE_MSGS, or the + * deadline expires — see _vcontact_hold_expiry for why the latch needs + * a second exit. */ + _vcontact_hold_msgwait = true; + _vcontact_hold_expiry = _ms->getMillis() + VCONTACT_HOLD_MAX_MS; + /* An app-side delete only hides the v-contact for the session it + * happened in — this is that session boundary, so it comes back. */ + _vcontact_app_hidden = false; + + /* If a time source already ran (hardware RTC, GPS), activate the + * deferred v-contact and flush buffered notices for this session. */ + vcontactClockSynced(); + + /* Re-stamp the v-contact once per app session so it stays "fresh". + * _vcontact_lastmod feeds both lastmod and last_advert_timestamp, and + * it used to move only on boot / rename / identity import: the app + * showed an ever-growing "last seen" age, and — worse — the contact + * sync gate is `_vcontact_lastmod > _contact_iter_since`, so after the + * first sync the v-contact was never streamed again and app-side state + * (flags, name) could never be corrected. Bumping here, before the + * CMD_GET_CONTACTS that follows APP_START, means every session's sync + * carries a current timestamp; deferring it to the end of sync would + * always land one session late. Silent on purpose — no NEW_ADVERT push + * mid-handshake; the sync itself delivers it. */ + if (vcontactReady() && vcontactClockValid()) { + _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); + } + + // Return SELF_INFO + uint8_t rsp[90]; // 58 fixed + up to 32 bytes name + size_t i = 0; + rsp[i++] = PACKET_SELF_INFO; + rsp[i++] = ADV_TYPE_CHAT; + rsp[i++] = prefs.tx_power_dbm; + rsp[i++] = MAX_LORA_TX_POWER; // Max allowed TX power + memcpy(&rsp[i], self_id.pub_key, PUB_KEY_SIZE); i += PUB_KEY_SIZE; + int32_t lat = (int32_t)(prefs.node_lat * 1000000.0); + int32_t lon = (int32_t)(prefs.node_lon * 1000000.0); + put_le32(&rsp[i], lat); i += 4; + put_le32(&rsp[i], lon); i += 4; + rsp[i++] = prefs.multi_acks; + rsp[i++] = prefs.advert_loc_policy; + // Telemetry modes: (env << 4) | (loc << 2) | base + rsp[i++] = (prefs.telemetry_mode_env << 4) | (prefs.telemetry_mode_loc << 2) | prefs.telemetry_mode_base; + rsp[i++] = prefs.manual_add_contacts; + put_le32(&rsp[i], (uint32_t)(prefs.freq * 1000)); i += 4; + put_le32(&rsp[i], (uint32_t)(prefs.bw * 1000)); i += 4; + rsp[i++] = prefs.sf; + rsp[i++] = prefs.cr; + size_t name_len = strnlen(prefs.node_name, sizeof(prefs.node_name) - 1); + memcpy(&rsp[i], prefs.node_name, name_len); + i += name_len; + writeFrame(rsp, i); + return true; +} + +bool CompanionMesh::handleCmdRunCliCommand(const uint8_t *data, size_t len) +{ + /* Runs a CLI line on behalf of the connected app. This is the same + * local CLI the USB text sideband and the v-contact chat already reach, + * over the same already-paired transport, so it adds no reach that an + * app connected to this node did not already have — it is executed with + * sender_timestamp 0 (local) for exactly that reason. */ + if (len >= 2) { + if (_cli_exec_cb == nullptr) { + writeErrFrame(ERR_UNSUPPORTED); + return true; + } + + /* `data` is const, so the line has to be copied out to be + * null-terminated. A command can never exceed one frame. */ + char line[MAX_FRAME_SIZE]; + size_t cmd_len = len - 1; + if (cmd_len > sizeof(line) - 1) cmd_len = sizeof(line) - 1; + memcpy(line, &data[1], cmd_len); + line[cmd_len] = '\0'; + + /* Reply is built in place behind the frame type byte. */ + uint8_t rsp[1 + COMPANION_CLI_PREFIX_ROOM + COMPANION_CLI_REPLY_SIZE]; + char *reply = (char *)&rsp[1]; + rsp[0] = PACKET_CLI_REPLY; + if (!handleCommand(line, 0, reply)) { + strcat(reply, "Unknown command"); // reply may have cmd prefix from 'line' + } + + /* A CommonCLI reply can be longer than one companion frame and the + * app protocol has no continuation for this response, so truncate + * rather than drop. */ + size_t rlen = strlen(reply); + if (rlen > MAX_FRAME_SIZE - 1) rlen = MAX_FRAME_SIZE - 1; + writeFrame(rsp, rlen + 1); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendTxtMsg(const uint8_t *data, size_t len) +{ + // Frame format: cmd(1) + txt_type(1) + attempt(1) + timestamp(4) + pub_key_prefix(6) + text(N) + // Minimum: 1 + 1 + 1 + 4 + 6 + 1 = 14 bytes + LOG_DBG("CMD_SEND_TXT_MSG: len=%u (min=14)", (unsigned)len); + if (len >= 14) { + int i = 1; + uint8_t txt_type = data[i++]; + uint8_t attempt = data[i++]; + uint32_t msg_timestamp = get_le32(&data[i]); i += 4; + const uint8_t *pub_key_prefix = &data[i]; i += 6; + + // Copy text and ensure null termination + size_t text_len = len - 13; // Everything after header (13 bytes) + char text_buf[MAX_TEXT_LEN + 1]; + if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; + memcpy(text_buf, &data[i], text_len); + text_buf[text_len] = '\0'; + const char *text = text_buf; + + LOG_DBG("CMD_SEND_TXT_MSG: txt_type=%d attempt=%d pubkey=%02x%02x%02x%02x%02x%02x", + txt_type, attempt, pub_key_prefix[0], pub_key_prefix[1], pub_key_prefix[2], + pub_key_prefix[3], pub_key_prefix[4], pub_key_prefix[5]); + + ContactInfo *contact = lookupContactByPubKey(pub_key_prefix, 6); + if (contact && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || + txt_type == TXT_TYPE_CLI_COMMAND)) { + LOG_DBG("CMD_SEND_TXT_MSG: contact='%s' text='%s' text_len=%u", contact->name, text, (unsigned)text_len); + + uint32_t expected_ack = 0, est_timeout; + int result; + + if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { + // Use node's RTC instead of app timestamp + msg_timestamp = getRTCClock()->getCurrentTimeUnique(); + result = sendCommandData(*contact, msg_timestamp, attempt, txt_type, text, + est_timeout); + } else { + result = sendMessage(*contact, msg_timestamp, attempt, text, expected_ack, est_timeout); + } + + LOG_DBG("CMD_SEND_TXT_MSG: sendMessage result=%d expected_ack=0x%08x", result, expected_ack); + + if (result != MSG_SEND_FAILED) { + if (expected_ack) { + // Track ACK + int idx = -1; + ContactInfo ci; + for (int k = 0; k < getNumContacts(); k++) { + if (getContactByIdx(k, ci) && ci.id.matches(contact->id)) { + idx = k; + break; + } + } + addPendingAck(expected_ack, idx); + } + + // Response: RESP_CODE_SENT + is_flood(1) + expected_ack(4) + est_timeout(4) + sendPacketSent(result, expected_ack, est_timeout); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + LOG_WRN("CMD_SEND_TXT_MSG: contact not found or unsupported txt_type=%d", txt_type); + writeErrFrame(contact == nullptr ? ERR_NOT_FOUND : ERR_UNSUPPORTED); + } + return true; + } + LOG_WRN("CMD_SEND_TXT_MSG: frame too short (len=%u, need 14)", (unsigned)len); + writeErrFrame(ERR_ILLEGAL_ARG); + return true; +} + +bool CompanionMesh::handleCmdSendChannelTxtMsg(const uint8_t *data, size_t len) +{ + // Arduino format: [cmd][txt_type][channel_idx][4-byte timestamp][text...] + // Minimum: 1 + 1 + 1 + 4 + 1 = 8 bytes + if (len < 8) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + { + int i = 1; + uint8_t txt_type = data[i++]; + uint8_t ch_idx = data[i++]; + uint32_t msg_timestamp = get_le32(&data[i]); + i += 4; + const char *text = (const char *)&data[i]; + size_t text_len = len - i; + + if (txt_type != TXT_TYPE_PLAIN) { + writeErrFrame(ERR_UNSUPPORTED); + } else { + ChannelDetails ch; + if (getChannel(ch_idx, ch)) { + if (sendGroupMessage(msg_timestamp, ch.channel, prefs.node_name, text, text_len)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } + return true; + } +} + +bool CompanionMesh::handleCmdSendChannelData(const uint8_t *data, size_t len) +{ + /* Minimum frame: cmd(1) + channel_idx(1) + path_len(1) + path(0..) + + * data_type(2) + payload(0..) = 5 bytes when path is empty. */ + if (len < 5) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + int i = 1; + uint8_t channel_idx = data[i++]; + uint8_t path_len = data[i++]; + + if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { + LOG_WRN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + + /* Compute decoded path byte count and ensure source frame has room + * for path + data_type. (path_len is the 6-bit hash_count + 2-bit + * hash_size encoding; writePath itself will reject if src_len is + * too small, but failing here also rejects truncated data_type.) */ + uint8_t hash_count = path_len & 63; + uint8_t hash_size = (path_len >> 6) + 1; + size_t path_bytes = (path_len == OUT_PATH_UNKNOWN) ? 0 + : (size_t)hash_count * hash_size; + if ((size_t)i + path_bytes + 2 > len) { + LOG_WRN("CMD_SEND_CHANNEL_DATA short frame: len=%u need >=%u", + (unsigned)len, (unsigned)(i + path_bytes + 2)); + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + + uint8_t path[MAX_PATH_SIZE]; + if (path_len != OUT_PATH_UNKNOWN) { + /* src_len = remaining bytes from data[i] onward. */ + i += mesh::Packet::writePath(path, &data[i], len - i, path_len); + } + + uint16_t data_type = ((uint16_t)data[i]) | (((uint16_t)data[i + 1]) << 8); + i += 2; + const uint8_t *payload = &data[i]; + int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; + + ChannelDetails channel; + if (!getChannel(channel_idx, channel)) { + writeErrFrame(ERR_NOT_FOUND); + } else if (data_type == DATA_TYPE_RESERVED) { + writeErrFrame(ERR_ILLEGAL_ARG); + } else if (payload_len > MAX_GROUP_DATA_LENGTH) { + /* Radio-side bound (165), NOT the host-frame bound (167). These + * disagree, and sendGroupData() enforces the smaller one, so gating + * here on MAX_CHANNEL_DATA_LENGTH let a 166/167-byte payload pass the + * frame check, fail the radio check, and come back to the app as + * ERR_TABLE_FULL — the "outbound queue full, retry later" code — for a + * send that can never succeed at any queue depth. Upstream 0a82fcd2. */ + LOG_WRN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); + writeErrFrame(ERR_ILLEGAL_ARG); + } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + return true; +} + +bool CompanionMesh::handleCmdGetContacts(const uint8_t *data, size_t len) +{ + if (_contact_iter_active) { + writeErrFrame(ERR_BAD_STATE); + } else { + // Parse optional 'since' parameter for incremental sync + if (len >= 5) { + _contact_iter_since = get_le32(&data[1]); + } else { + _contact_iter_since = 0; + } + + // Send PACKET_CONTACT_START with total count (unfiltered, but excluding + // transient anon slots -- continueContactIteration() never streams those) + _contact_iter_num = getNumContacts(); + _contact_iter_vc = vcontactReady(); /* virtual tail entry (post time sync) */ + uint32_t total = 0; + for (int i = 0; i < _contact_iter_num; i++) { + ContactInfo c; + if (getContactByIdx(i, c) && c.type != ADV_TYPE_NONE) total++; + } + if (_contact_iter_vc) total++; + uint8_t rsp[5]; + rsp[0] = PACKET_CONTACT_START; + put_le32(&rsp[1], total); + writeFrame(rsp, sizeof(rsp)); + _contact_iter_idx = 0; + _contact_iter_lastmod = 0; + _contact_iter_active = true; + } + return true; +} + +bool CompanionMesh::handleCmdSetAdvertName(const uint8_t *data, size_t len) +{ + if (len >= 2) { + size_t nlen = len - 1; + if (nlen >= sizeof(prefs.node_name)) nlen = sizeof(prefs.node_name) - 1; + memcpy(prefs.node_name, &data[1], nlen); + prefs.node_name[nlen] = '\0'; + _store->savePrefs(prefs); + /* Push the new name to BLE so scanners see it without a reboot. */ + zephcore_ble_update_name(prefs.node_name); + /* v-contact name tracks the node name — update the app's copy. */ + vcontactPushAdvert(); + } + writeOKFrame(); + return true; +} + +bool CompanionMesh::handleCmdSetAdvertLatlon(const uint8_t *data, size_t len) +{ + if (len >= 9) { + int32_t lat = (int32_t)get_le32(&data[1]); + int32_t lon = (int32_t)get_le32(&data[5]); + // Arduino validation: lat ±90°, lon ±180° (in microdegrees) + if (lat >= -90000000 && lat <= 90000000 && + lon >= -180000000 && lon <= 180000000) { + prefs.node_lat = (double)lat / 1000000.0; + prefs.node_lon = (double)lon / 1000000.0; + _store->savePrefs(prefs); + LOG_INF("SET_ADVERT_LATLON: lat=%d lon=%d policy=%d", + lat, lon, prefs.advert_loc_policy); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetDeviceTime(const uint8_t *data, size_t len) +{ + uint8_t rsp[5]; + rsp[0] = PACKET_CURR_TIME; + put_le32(&rsp[1], (uint32_t)getRTCClock()->getCurrentTime()); + writeFrame(rsp, sizeof(rsp)); + return true; +} + +bool CompanionMesh::handleCmdSetDeviceTime(const uint8_t *data, size_t len) +{ + if (len >= 5) { + /* If we have GPS-synced time, ignore phone time sync attempts. + * GPS time is more accurate than phone time. Still return OK + * so the app doesn't keep retrying. */ + if (gps_has_time_sync()) { + LOG_DBG("Ignoring phone time sync - GPS time sync active"); + writeOKFrame(); + vcontactClockSynced(); /* GPS time counts as valid too */ + return true; + } + + uint32_t secs = get_le32(&data[1]); + uint32_t curr = getRTCClock()->getCurrentTime(); + // Arduino: only allow setting time forward (prevents time attacks) + if (secs >= curr) { + getRTCClock()->setCurrentTime(secs); + time_sync_report(TIME_SYNC_APP); + zephcore_rtc_save(secs); /* persist to hardware RTC */ + _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); + writeOKFrame(); + /* App just gave us wall time — activate the deferred + * v-contact and flush buffered notices. */ + vcontactClockSynced(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendSelfAdvert(const uint8_t *data, size_t len) +{ + /* Optional param: data[1] == 1 means flood, else zero-hop */ + bool flood = (len >= 2 && data[1] == 1); + if (sendSelfAdvert(flood)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + return true; +} + +bool CompanionMesh::handleCmdResetPath(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (c) { + resetPathTo(*c); + markContactsDirty(); + writeOKFrame(); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdAddUpdateContact(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE + 1 + 1 + 1 + MAX_PATH_SIZE + 32 + 4) { + const uint8_t *pub_key = &data[1]; + ContactInfo *existing = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + + ContactInfo c; + size_t i = 1; + memcpy(c.id.pub_key, &data[i], PUB_KEY_SIZE); i += PUB_KEY_SIZE; + c.type = data[i++]; + c.flags = data[i++]; + c.out_path_len = data[i++]; + memcpy(c.out_path, &data[i], MAX_PATH_SIZE); i += MAX_PATH_SIZE; + memcpy(c.name, &data[i], 32); i += 32; + c.name[31] = '\0'; /* defensive null-term — matches CMD_SET_CHANNEL pattern */ + c.last_advert_timestamp = get_le32(&data[i]); i += 4; + c.gps_lat = 0; + c.gps_lon = 0; + c.lastmod = (uint32_t)getRTCClock()->getCurrentTime(); + if (len >= i + 8) { + c.gps_lat = (int32_t)get_le32(&data[i]); i += 4; + c.gps_lon = (int32_t)get_le32(&data[i]); i += 4; + if (len >= i + 4) { + c.lastmod = get_le32(&data[i]); + } + } + c.sync_since = 0; + c.shared_secret_valid = false; + + if (existing) { + // Update existing + *existing = c; + markContactsDirty(); + writeOKFrame(); + } else if (addContact(c)) { + markContactsDirty(); + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdRemoveContact(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (c && removeContact(*c)) { + _store->deleteBlobByKey(&data[1], PUB_KEY_SIZE); + markContactsDirty(); + writeOKFrame(); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdShareContact(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (c && shareContactZeroHop(*c)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetContactByKey(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (c) { + uint8_t rsp[CONTACT_FRAME_SIZE]; + size_t n = serializeContact(rsp, *c, PACKET_CONTACT); + writeFrame(rsp, n); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdExportContact(const uint8_t *data, size_t len) +{ + if (len < 1 + PUB_KEY_SIZE) { + // Export SELF - create self advert packet + mesh::Packet *pkt; + if (prefs.advert_loc_policy == 0) { // ADVERT_LOC_NONE + pkt = createSelfAdvert(prefs.node_name); + } else { + pkt = createSelfAdvert(prefs.node_name, prefs.node_lat, prefs.node_lon); + } + if (pkt) { + pkt->header |= ROUTE_TYPE_FLOOD; + uint8_t rsp[128]; + rsp[0] = PACKET_EXPORT_CONTACT; + uint8_t out_len = pkt->writeTo(&rsp[1]); + releasePacket(pkt); + writeFrame(rsp, out_len + 1); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + // Export specific contact by pubkey + ContactInfo *c = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (c) { + uint8_t rsp[128]; + rsp[0] = PACKET_EXPORT_CONTACT; + uint8_t export_len = exportContact(*c, &rsp[1]); + if (export_len > 0) { + writeFrame(rsp, export_len + 1); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } + return true; +} + +bool CompanionMesh::handleCmdImportContact(const uint8_t *data, size_t len) +{ + // Arduino: len > 2 + 32 + 64 = 98 bytes (header + pubkey + signature) + if (len > 2 + 32 + 64) { + if (importContact(&data[1], len - 1)) { + markContactsDirty(); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); // Match Arduino error code + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSyncNextMessage(const uint8_t *data, size_t len) +{ + LOG_DBG("CMD_SYNC_NEXT_MESSAGE: queue_count=%d pending=%d", + _offline_queue_count, _sync_pending); + + /* The app is draining — hold the watchdog off for another window. */ + _last_sync_req_ms = _ms->getMillis(); + LOG_INF("msgwait: app asked for next msg, %d queued", + _offline_queue_count); + + /* Phone asking for next = implicit ACK for the previously-peeked message */ + if (_sync_pending) { + confirmOfflineMessage(); + _sync_pending = false; +#if ZEPHCORE_HAS_UI_TASK + ui_set_msg_count((uint16_t)_offline_queue_count); +#endif + } + + uint8_t buf[MAX_FRAME_SIZE]; + size_t msg_len; + if (peekOfflineMessage(buf, msg_len)) { + LOG_DBG("CMD_SYNC_NEXT_MESSAGE: peeked msg_len=%u type=0x%02x", (unsigned)msg_len, buf[0]); + if (writeFrame(buf, msg_len)) { + _sync_pending = true; /* confirmed on next request or lost on disconnect */ + } + } else { + LOG_DBG("CMD_SYNC_NEXT_MESSAGE: queue empty, sending NO_MORE_MSGS"); + uint8_t rsp[] = { PACKET_NO_MORE_MSGS }; + if (!writeFrame(rsp, sizeof(rsp))) { + return true; + } + + /* Initial sync is done — safe to apply deferred + * connection parameters now without disrupting + * channel/contact/message throughput. */ + zephcore_ble_conn_params_ready(); + + /* Full initial sync (contacts + messages) complete: stop + * suppressing v-contact notice prompts. Any notice queued during + * the window was just drained by this message-sync; if one raced in + * after the last peek, prompt for it now. */ + _vcontact_hold_msgwait = false; + _vcontact_hold_expiry = 0; + if (_offline_queue_count > 0) { + pushMsgWaiting(); + } + } + return true; +} + +bool CompanionMesh::handleCmdSetRadioParams(const uint8_t *data, size_t len) +{ + if (len >= 11) { + uint32_t freq = get_le32(&data[1]); + uint32_t bw = get_le32(&data[5]); + uint8_t sf = data[9]; + uint8_t cr = data[10]; + uint8_t repeat = 0; + if (len > 11) { + repeat = data[11]; // v9+: client repeat flag + } + // If repeat requested, validate frequency against allowed bands + if (repeat && !isValidClientRepeatFreq(freq)) { + writeErrFrame(ERR_ILLEGAL_ARG); + /* freq arrives in kHz, bw in Hz. Same limits as the USB + * CLI and the prefs loader — see NodePrefs.h. Accepting + * here what loadPrefs() later rejects is how a 2.4 GHz + * setting used to vanish on reboot. */ + } else if (freq >= (uint32_t)(ZC_RADIO_FREQ_MIN_MHZ * 1000.0f) && + freq <= (uint32_t)(ZC_RADIO_FREQ_MAX_MHZ * 1000.0f) && + bw >= (uint32_t)(ZC_RADIO_BW_MIN_KHZ * 1000.0f) && + bw <= (uint32_t)(ZC_RADIO_BW_MAX_KHZ * 1000.0f) && + sf >= 5 && sf <= 12 && + cr >= 5 && cr <= 8) { + /* Coding rate is deliberately not in this test: + * it changes airtime, not the channel or the + * per-SF/per-bandwidth detPeak base, so it does + * not invalidate the learned CAD offset. */ + bool preset_changed = + prefs.freq != (float)freq / 1000.0f || + prefs.bw != (float)bw / 1000.0f || + prefs.sf != sf; + prefs.freq = (float)freq / 1000.0f; + prefs.bw = (float)bw / 1000.0f; + prefs.sf = sf; + prefs.cr = cr; + prefs.client_repeat = repeat; + _store->savePrefs(prefs); + if (_radio_reconfig_cb) _radio_reconfig_cb(preset_changed); + LOG_INF("SET_RADIO_PARAMS: client_repeat=%d", repeat); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSetRadioTxPower(const uint8_t *data, size_t len) +{ + if (len >= 2) { + int8_t power = (int8_t)data[1]; + if (power >= -9 && power <= MAX_LORA_TX_POWER) { + prefs.tx_power_dbm = power; + _store->savePrefs(prefs); + if (_radio_reconfig_cb) _radio_reconfig_cb(false); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSetTuningParams(const uint8_t *data, size_t len) +{ + if (len < 9) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + prefs.rx_delay_base = (float)get_le32(&data[1]) / 1000.0f; + prefs.airtime_factor = (float)get_le32(&data[5]) / 1000.0f; + _store->savePrefs(prefs); + writeOKFrame(); + return true; +} + +bool CompanionMesh::handleCmdGetTuningParams(const uint8_t *data, size_t len) +{ + uint8_t rsp[9]; + rsp[0] = PACKET_TUNING_PARAMS; + put_le32(&rsp[1], (uint32_t)(prefs.rx_delay_base * 1000)); + put_le32(&rsp[5], (uint32_t)(prefs.airtime_factor * 1000)); + writeFrame(rsp, sizeof(rsp)); + return true; +} + +bool CompanionMesh::handleCmdSetOtherParams(const uint8_t *data, size_t len) +{ + if (len >= 2) { + prefs.manual_add_contacts = data[1]; + if (len >= 3) { + // Telemetry modes: (env << 4) | (loc << 2) | base + prefs.telemetry_mode_base = data[2] & 0x03; + prefs.telemetry_mode_loc = (data[2] >> 2) & 0x03; + prefs.telemetry_mode_env = (data[2] >> 4) & 0x03; + if (len >= 4) { + prefs.advert_loc_policy = data[3]; + LOG_INF("SET_OTHER_PARAMS: advert_loc_policy=%d", prefs.advert_loc_policy); + if (len >= 5) { + prefs.multi_acks = data[4]; + } + } + } + _store->savePrefs(prefs); + } + writeOKFrame(); + return true; +} + +bool CompanionMesh::handleCmdSetPathHashMode(const uint8_t *data, size_t len) +{ + if (len >= 3 && data[1] == 0) { + if (data[2] >= 3) { + writeErrFrame(ERR_ILLEGAL_ARG); + } else { + prefs.path_hash_mode = data[2]; + _store->savePrefs(prefs); + writeOKFrame(); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdReboot(const uint8_t *data, size_t len) +{ + if (len >= 7 && memcmp(&data[1], "reboot", 6) == 0) { + LOG_INF("Reboot requested"); + /* Flush any pending lazy writes before reboot */ + flushDirtyContacts(); + flushDirtyChannels(); + writeOKFrame(); + sys_reboot(SYS_REBOOT_COLD); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetBattAndStorage(const uint8_t *data, size_t len) +{ + uint8_t rsp[11]; + rsp[0] = PACKET_BATTERY; + put_le16(&rsp[1], _batt_cb ? _batt_cb() : 0); + put_le32(&rsp[3], _store->getStorageUsedKb()); + put_le32(&rsp[7], _store->getStorageTotalKb()); + writeFrame(rsp, sizeof(rsp)); + return true; +} + +bool CompanionMesh::handleCmdExportPrivateKey(const uint8_t *data, size_t len) +{ + // Response: [PACKET_PRIVATE_KEY=0x0E][64 bytes: 32 private + 32 public] + uint8_t rsp[65]; + rsp[0] = PACKET_PRIVATE_KEY; + self_id.writeTo(&rsp[1], 64); + writeFrame(rsp, sizeof(rsp)); + return true; +} + +bool CompanionMesh::handleCmdImportPrivateKey(const uint8_t *data, size_t len) +{ + /* Import private key: [cmd][64 bytes prv_key] */ + if (len >= 1 + PRV_KEY_SIZE) { + if (!mesh::LocalIdentity::validatePrivateKey(&data[1])) { + writeErrFrame(ERR_ILLEGAL_ARG); /* invalid key */ + } else { + mesh::LocalIdentity new_identity; + new_identity.readFrom(&data[1], PRV_KEY_SIZE); + if (_store->saveMainIdentity(new_identity)) { + /* The v-contact key is derived from our identity, so it + * changes with the new key. Tell the connected app to drop + * the old v-contact (old key), re-derive, then re-advertise + * the new one — otherwise the app's cached loopback contact + * points at a key we no longer recognise (messaging + its + * advert-path query break until a reboot re-syncs). Order + * matters: delete uses the CURRENT (old) key. */ + bool vc_was_enabled = isVContactEnabled(); + if (vc_was_enabled) vcontactPushDeleted(); + self_id = new_identity; + deriveVContactKey(); + /* New identity → old advert timestamp is meaningless. Clear + * it so vcontactPushAdvert() re-activates cleanly: with a + * valid clock it stamps + emits now; without one it defers + * and vcontactClockSynced() (the lastmod==0 path) emits at the + * next time-sync instead of only after a reboot. */ + _vcontact_lastmod = 0; + /* Different key → different contact in the app; the old + * favourite/telemetry flags don't carry over. Persist + * before the push so a reboot can't resurrect them. */ + if (prefs.v_contact_flags != 0) { + prefs.v_contact_flags = 0; + _store->savePrefs(prefs); + } + if (vc_was_enabled) vcontactPushAdvert(); + /* Reload contacts to invalidate ECDH shared secrets */ + resetContacts(); + _store->loadContacts(this); + writeOKFrame(); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendRawData(const uint8_t *data, size_t len) +{ + /* Raw data packet: [cmd][path_len][path...][payload...] + * + * path_len is the encoded 2-bit (hash_size - 1) + 6-bit hop count, not + * a byte count, so it has to be decoded rather than added to an offset. + * Treating it as a length rejected every path with a hash size above 1: + * hash_size 2 sets bit 6 (path_len 0x42 for two hops read as 66 bytes, + * failing the length check) and hash_size 3 sets bit 7, which used to + * read back as a negative int8_t. */ + if (len >= 6) { /* min: cmd + path_len + 4 byte payload */ + size_t i = 1; + uint8_t path_len = data[i++]; + if (mesh::Packet::isValidPathLen(path_len)) { + uint8_t path[MAX_PATH_SIZE]; + /* Untrusted phone frame: bound the source at the bytes that + * actually remain, so a crafted path_len cannot over-read. */ + size_t path_bytes = mesh::Packet::writePath(path, &data[i], len - i, path_len); + if (path_bytes == 0 && (path_len & 63) != 0) { + /* Zero bytes written with a non-zero hop count (low 6 + * bits): the decoded path runs past the end of the frame. */ + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + i += path_bytes; + if (i + 4 > len) { /* min payload 4 bytes */ + writeErrFrame(ERR_ILLEGAL_ARG); + } else { + mesh::Packet *pkt = createRawData(&data[i], len - i); + if (pkt) { + sendDirect(pkt, path, path_len); + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } + } else { + /* Flood mode not supported */ + writeErrFrame(ERR_UNSUPPORTED); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendLogin(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + size_t pw_len = len - 1 - PUB_KEY_SIZE; + char pw_buf[64]; + if (pw_len >= sizeof(pw_buf)) pw_len = sizeof(pw_buf) - 1; + memcpy(pw_buf, &data[1 + PUB_KEY_SIZE], pw_len); + pw_buf[pw_len] = '\0'; + const char *password = pw_buf; + if (contact) { + uint32_t est_timeout; + LOG_DBG("CMD_SEND_LOGIN: sending to contact, path_len=%d", contact->out_path_len); + int result = sendLogin(*contact, password, est_timeout); + LOG_DBG("CMD_SEND_LOGIN: sendLogin returned %d, est_timeout=%u", result, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&_pending_login, contact->id.pub_key, 4); // match this to onContactResponse() + LOG_DBG("CMD_SEND_LOGIN: _pending_login set to %08x", _pending_login); + sendPacketSent(result, _pending_login, est_timeout); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendAnonReq(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE + 1) { + ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (contact == nullptr) { + /* FIRMWARE_VER_CODE 13+: allow requests to a non-contact pubkey by + * creating a transient "anon" contact (ADV_TYPE_NONE). These are never + * persisted or synced to the app; re-lookup to get the stable slot. */ + ContactInfo anon{}; + memcpy(anon.id.pub_key, &data[1], PUB_KEY_SIZE); + anon.out_path_len = 0; // zero-hop direct by default + anon.type = ADV_TYPE_NONE; // transient/unknown + anon.lastmod = getRTCClock()->getCurrentTime(); // so slot recycling is LRU, not always slot 0 + if (addContact(anon)) { + contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + } + } + if (contact) { + uint32_t tag, est_timeout; + int result = sendAnonReq(*contact, &data[1 + PUB_KEY_SIZE], len - 1 - PUB_KEY_SIZE, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + _pending_req = tag; + sendPacketSent(result, tag, est_timeout); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendStatusReq(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (contact) { + uint32_t tag, est_timeout; + int result = sendRequest(*contact, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&_pending_status, contact->id.pub_key, 4); // legacy matching scheme + sendPacketSent(result, tag, est_timeout); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendPathDiscoveryReq(const uint8_t *data, size_t len) +{ + if (len >= 2 + PUB_KEY_SIZE && data[1] == 0) { + // Path Discovery is a special flood + Telemetry request + ContactInfo *contact = lookupContactByPubKey(&data[2], PUB_KEY_SIZE); + if (contact) { + uint32_t tag, est_timeout; + // Build telemetry request with only BASE permissions + uint8_t req_data[9]; + req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; + req_data[1] = ~(TELEM_PERM_BASE); // inverse permissions mask + memset(&req_data[2], 0, 3); // reserved + getRNG()->random(&req_data[5], 4); // random to make packet-hash unique + + // Temporarily force flood + auto save = contact->out_path_len; + contact->out_path_len = OUT_PATH_UNKNOWN; + int result = sendRequest(*contact, req_data, sizeof(req_data), tag, est_timeout); + contact->out_path_len = save; + + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + _pending_discovery = tag; + sendPacketSent(result, tag, est_timeout); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendTelemetryReq(const uint8_t *data, size_t len) +{ + if (len == 4) { + // Self-telemetry request: return battery, GPS, and environment data + // Format: Cayenne LPP: [channel][type][data...] + // Response: [PUSH_CODE_TELEMETRY_RESPONSE][reserved][6-byte pubkey][telemetry_data] + // Worst-case size tracks POWER_MAX_CHANNELS so a future bump can't + // silently overflow this stack buffer. With current value 4: + // header(8) + batt(4) + gps(11) + env(temp4+hum3+press4+lum4=15) + // + power(POWER_MAX_CHANNELS * 12 = 48) + 8 byte safety pad = 94. + uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; + int i = 0; + rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; + rsp[i++] = 0; // reserved + memcpy(&rsp[i], self_id.pub_key, 6); + i += 6; // pubkey prefix + + // Self-telemetry is unconditional (all permission bits set). + i += appendSelfTelemetry(&rsp[i], TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT); + writeFrame(rsp, i); + } else if (len >= 4 + PUB_KEY_SIZE) { + // Contact telemetry request: [cmd][3 reserved bytes][32-byte pubkey] + ContactInfo *contact = lookupContactByPubKey(&data[4], PUB_KEY_SIZE); + if (contact) { + uint32_t tag, est_timeout; + int result = sendRequest(*contact, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + _pending_telemetry = tag; + sendPacketSent(result, tag, est_timeout); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSendBinaryReq(const uint8_t *data, size_t len) +{ + // Format: [cmd][32-byte pubkey][req_data...] + if (len >= 1 + PUB_KEY_SIZE + 1) { // Need at least cmd + pubkey + 1 byte req_data + ContactInfo *contact = lookupContactByPubKey(&data[1], PUB_KEY_SIZE); + if (contact) { + uint32_t tag, est_timeout; + // req_data starts after pubkey + const uint8_t *req_data = &data[1 + PUB_KEY_SIZE]; + size_t req_len = len - (1 + PUB_KEY_SIZE); + int result = sendRequest(*contact, req_data, req_len, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + _pending_req = tag; + sendPacketSent(result, tag, est_timeout); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdHasConnection(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + if (hasConnectionTo(&data[1])) { + writeOKFrame(); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdLogout(const uint8_t *data, size_t len) +{ + if (len >= 1 + PUB_KEY_SIZE) { + stopConnection(&data[1]); + } + writeOKFrame(); + return true; +} + +bool CompanionMesh::handleCmdGetChannel(const uint8_t *data, size_t len) +{ + // Format: [cmd][channel_idx] + // Response: [code][idx][32 name][16 secret] = 50 bytes (matches Arduino) + // Arduino returns channel info even if slot is empty (name[0]=='\0') + if (len < 2 || data[1] >= MAX_GROUP_CHANNELS) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + { + static int64_t last_get_channel_ms; + int64_t now_ms = _ms->getMillis(); + uint32_t dt_ms = last_get_channel_ms ? (uint32_t)(now_ms - last_get_channel_ms) : 0; + last_get_channel_ms = now_ms; + LOG_DBG("CMD_GET_CHANNEL idx=%u dt=%ums", data[1], dt_ms); + drainPendingChannelInfos(); + if (!enqueuePendingChannelInfo(data[1])) { + writeErrFrame(ERR_BAD_STATE); + return true; + } + drainPendingChannelInfos(); + return true; + } +} + +bool CompanionMesh::handleCmdSetChannel(const uint8_t *data, size_t len) +{ + // Format: [cmd][idx][32 name][16 or 32 secret] + // Arduino rejects 32-byte secrets, but we accept 16-byte (50 bytes total) + if (len < 2 || data[1] >= MAX_GROUP_CHANNELS) { + writeErrFrame(ERR_ILLEGAL_ARG); + return true; + } + if (len >= 2 + 32 + 32) { + // 32-byte secret not supported (matches Arduino) + writeErrFrame(ERR_UNSUPPORTED); + return true; + } + if (len >= 2 + 32 + 16) { + ChannelDetails ch; + // Copy name (null-terminate if needed) + memcpy(ch.name, &data[2], 32); + ch.name[31] = '\0'; // ensure null termination + // Copy 16-byte secret, zero upper 16 bytes + memset(ch.channel.secret, 0, sizeof(ch.channel.secret)); + memcpy(ch.channel.secret, &data[2 + 32], 16); + // setChannel computes the hash based on whether upper 16 bytes are zero + if (setChannel(data[1], ch)) { + markChannelsDirty(); + writeOKFrame(); + } else { + writeErrFrame(ERR_NOT_FOUND); // bad channel_idx + } + return true; + } + writeErrFrame(ERR_ILLEGAL_ARG); + return true; +} + +bool CompanionMesh::handleCmdSignStart(const uint8_t *data, size_t len) +{ + // Free any previous signing state + if (_sign_data) { + delete[] _sign_data; + _sign_data = nullptr; + } + _sign_data_len = 0; + _sign_data_capacity = 0; + // Allocate new buffer + _sign_data = new uint8_t[MAX_SIGN_DATA_LEN]; + if (_sign_data) { + _sign_data_capacity = MAX_SIGN_DATA_LEN; + // Response: [code][reserved][4-byte max_len] = 6 bytes (matches Arduino) + uint8_t rsp[6]; + rsp[0] = PACKET_SIGN_START; + rsp[1] = 0; // reserved + uint32_t max_len = MAX_SIGN_DATA_LEN; + put_le32(&rsp[2], max_len); + writeFrame(rsp, sizeof(rsp)); + } else { + writeErrFrame(ERR_BAD_STATE); + } + return true; +} + +bool CompanionMesh::handleCmdSignData(const uint8_t *data, size_t len) +{ + if (_sign_data && len > 1) { + size_t chunk_len = len - 1; + if (_sign_data_len + chunk_len <= _sign_data_capacity) { + memcpy(&_sign_data[_sign_data_len], &data[1], chunk_len); + _sign_data_len += chunk_len; + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_BAD_STATE); + } + return true; +} + +bool CompanionMesh::handleCmdSignFinish(const uint8_t *data, size_t len) +{ + if (_sign_data && _sign_data_len > 0) { + uint8_t signature[64]; + // Sign the data using Ed25519 via LocalIdentity method + self_id.sign(signature, _sign_data, _sign_data_len); + uint8_t rsp[1 + 64]; + rsp[0] = PACKET_SIGNATURE; + memcpy(&rsp[1], signature, 64); + writeFrame(rsp, sizeof(rsp)); + // Clean up + delete[] _sign_data; + _sign_data = nullptr; + _sign_data_len = 0; + _sign_data_capacity = 0; + } else { + writeErrFrame(ERR_BAD_STATE); + } + return true; +} + +bool CompanionMesh::handleCmdSendTracePath(const uint8_t *data, size_t len) +{ + /* Trace path: [cmd][4-byte tag][4-byte auth][flags][path...] */ + if (len > 10 && (len - 10) < MAX_PACKET_PAYLOAD - 5) { + uint8_t path_len = len - 10; + uint8_t flags = data[9]; + uint8_t path_sz = flags & 0x03; /* Path hash size encoding */ + /* Validate path length is multiple of hash size */ + if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { + writeErrFrame(ERR_ILLEGAL_ARG); + } else { + uint32_t tag = get_le32(&data[1]); + uint32_t auth_code = get_le32(&data[5]); + mesh::Packet *trace = createTrace(tag, auth_code, flags); + if (trace) { + sendDirect(trace, &data[10], path_len); + + uint32_t airtime = _radio->getEstAirtimeFor(trace->payload_len + trace->path_len + 2); + uint32_t est_timeout = calcDirectTimeoutMillisFor(airtime, path_len >> path_sz); + + uint8_t rsp[10]; + rsp[0] = PACKET_SENT; + rsp[1] = 0; /* Not flood */ + put_le32(&rsp[2], tag); + put_le32(&rsp[6], est_timeout); + writeFrame(rsp, sizeof(rsp)); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } + } else if (len >= 9) { + /* Legacy: zero-hop trace (backwards compat) */ + uint32_t tag = get_le32(&data[1]); + uint32_t auth_code = get_le32(&data[5]); + mesh::Packet *trace = createTrace(tag, auth_code); + if (trace) { + sendZeroHop(trace); + writeOKFrame(); + } else { + writeErrFrame(ERR_BAD_STATE); + } + } else { + writeOKFrame(); /* Backwards compat */ + } + return true; +} + +bool CompanionMesh::handleCmdSetDevicePin(const uint8_t *data, size_t len) +{ + if (len >= 5) { + uint32_t pin; + memcpy(&pin, &data[1], 4); + if (pin == 0 || (pin >= 100000 && pin <= 999999)) { + prefs.ble_pin = pin; + _store->savePrefs(prefs); + if (_pin_change_cb) _pin_change_cb(pin); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetCustomVars(const uint8_t *data, size_t len) +{ + // Return GPS and sensor settings as comma-separated key:value pairs + // Format: [PACKET_CUSTOM_VARS][key1:val1,key2:val2,...] + uint8_t rsp[64]; + char *dp = (char *)&rsp[1]; + char *const rsp_end = (char *)&rsp[sizeof(rsp)]; + rsp[0] = PACKET_CUSTOM_VARS; + + // snprintf returns the would-be length, NOT bytes actually written. + // We must check truncation and only advance dp on real progress — + // otherwise dp can outrun the initialized portion of rsp and + // writeFrame(rsp, dp-rsp) leaks adjacent stack to the phone. + + bool first = true; + if (gps_is_available()) { + size_t remaining = (size_t)(rsp_end - dp); + int n = snprintf(dp, remaining, "gps:%d", gps_is_enabled() ? 1 : 0); + if (n > 0 && (size_t)n < remaining) { + dp += n; + first = false; + } + } + uint32_t gps_interval = gps_get_poll_interval_sec(); + if (gps_interval > 0) { + size_t remaining = (size_t)(rsp_end - dp); + // Reserve 1 byte for the leading ',' if needed + if (!first && remaining > 0) { + *dp++ = ','; + remaining--; + } + int n = snprintf(dp, remaining, "gps_interval:%u", (unsigned)gps_interval); + if (n > 0 && (size_t)n < remaining) { + dp += n; + first = false; + } + // If snprintf would have truncated, dp stays put — writeFrame + // sends only what we successfully wrote. + } + { + size_t remaining = (size_t)(rsp_end - dp); + if (!first && remaining > 0) { + *dp++ = ','; + remaining--; + } + int n = snprintf(dp, remaining, "meshtimesync:%d", + prefs.meshtimesync ? 1 : 0); + if (n > 0 && (size_t)n < remaining) { + dp += n; + } + } + // Note: Environment sensors are auto-detected, no settings needed + + writeFrame(rsp, (size_t)(dp - (char *)rsp)); + return true; +} + +bool CompanionMesh::handleCmdSetCustomVar(const uint8_t *data, size_t len) +{ + // Format: [cmd][key:value] + if (len >= 4) { + char buf[64]; + size_t copy_len = (len - 1 < sizeof(buf) - 1) ? len - 1 : sizeof(buf) - 1; + memcpy(buf, &data[1], copy_len); + buf[copy_len] = '\0'; + + char *sep = strchr(buf, ':'); + if (sep) { + *sep = '\0'; + char *key = buf; + char *val = sep + 1; + + if (strcmp(key, "gps") == 0) { + bool enable = (val[0] == '1'); + gps_enable(enable); + /* Persist GPS state across reboots */ + prefs.gps_enabled = enable ? 1 : 0; + _store->savePrefs(prefs); + writeOKFrame(); + } else if (strcmp(key, "gps_interval") == 0) { + uint32_t interval = (uint32_t)atoi(val); + if (interval > 0 && interval <= 86400) { + gps_set_poll_interval_sec(interval); + /* Persist GPS interval across reboots */ + prefs.gps_interval = interval; + _store->savePrefs(prefs); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else if (strcmp(key, "meshtimesync") == 0) { + prefs.meshtimesync = (val[0] == '1') ? 1 : 0; + _store->savePrefs(prefs); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetAdvertPath(const uint8_t *data, size_t len) +{ + // Format: [cmd][reserved][7-byte pubkey prefix] + if (len >= 2 + 7) { + const AdvertPath *ap = findAdvertPath(&data[2], 7); // pubkey at offset 2 + if (ap) { + // Response: [code][4-byte timestamp][path_len][path...] + uint8_t rsp[6 + MAX_PATH_SIZE]; + size_t i = 0; + rsp[i++] = PACKET_ADVERT_PATH; + put_le32(&rsp[i], ap->recv_timestamp); i += 4; + rsp[i++] = ap->path_len; + /* Trusted source: AdvertPath::path is MAX_PATH_SIZE-sized. */ + i += mesh::Packet::writePath(&rsp[i], ap->path, MAX_PATH_SIZE, ap->path_len); + writeFrame(rsp, i); + } else { + writeErrFrame(ERR_NOT_FOUND); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdGetStats(const uint8_t *data, size_t len) +{ + if (len >= 2) { + uint8_t stats_type = data[1]; + if (stats_type == 0) { + // STATS_TYPE_CORE: battery_mv(2) + uptime_secs(4) + err_flags(2) + queue_len(1) + uint8_t rsp[11]; + size_t i = 0; + rsp[i++] = PACKET_STATS; + rsp[i++] = 0; // STATS_TYPE_CORE + put_le16(&rsp[i], _batt_cb ? _batt_cb() : 0); i += 2; + put_le32(&rsp[i], (uint32_t)(_ms->getMillis() / 1000)); i += 4; // uptime_secs + put_le16(&rsp[i], getErrFlags()); i += 2; + rsp[i++] = (uint8_t)_mgr->getOutboundCount((uint32_t)_ms->getMillis()); + writeFrame(rsp, i); + } else if (stats_type == 1) { + // STATS_TYPE_RADIO: noise_floor(2) + last_rssi(1) + last_snr(1) + tx_air_secs(4) + rx_air_secs(4) + uint8_t rsp[14]; + size_t i = 0; + rsp[i++] = PACKET_STATS; + rsp[i++] = 1; // STATS_TYPE_RADIO + put_le16(&rsp[i], (int16_t)_radio->getNoiseFloor()); i += 2; + rsp[i++] = (int8_t)_radio->getLastRSSI(); + rsp[i++] = (int8_t)(_radio->getLastSNR() * 4); // scaled by 4 + put_le32(&rsp[i], getTotalAirTime() / 1000); i += 4; // tx_air_secs + put_le32(&rsp[i], getReceiveAirTime() / 1000); i += 4; // rx_air_secs + writeFrame(rsp, i); + } else if (stats_type == 2) { + // STATS_TYPE_PACKETS: recv(4) + sent(4) + sent_flood(4) + sent_direct(4) + recv_flood(4) + recv_direct(4) + recv_errors(4) + uint8_t rsp[30]; + size_t i = 0; + rsp[i++] = PACKET_STATS; + rsp[i++] = 2; // STATS_TYPE_PACKETS + uint32_t sent_flood = getNumSentFlood(); + uint32_t sent_direct = getNumSentDirect(); + uint32_t recv_flood = getNumRecvFlood(); + uint32_t recv_direct = getNumRecvDirect(); + put_le32(&rsp[i], recv_flood + recv_direct); i += 4; // total recv + put_le32(&rsp[i], sent_flood + sent_direct); i += 4; // total sent + put_le32(&rsp[i], sent_flood); i += 4; + put_le32(&rsp[i], sent_direct); i += 4; + put_le32(&rsp[i], recv_flood); i += 4; + put_le32(&rsp[i], recv_direct); i += 4; + put_le32(&rsp[i], _radio->getPacketsRecvErrors()); i += 4; + writeFrame(rsp, i); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdFactoryReset(const uint8_t *data, size_t len) +{ + // Arduino: requires "reset" magic string for safety + if (len >= 6 && memcmp(&data[1], "reset", 5) == 0) { + LOG_INF("Factory reset requested"); + _store->factoryReset(); + writeOKFrame(); + // Reboot to regenerate identity + sys_reboot(SYS_REBOOT_COLD); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSetFloodScopeKey(const uint8_t *data, size_t len) +{ + /* Set send scope mode (Arduino MeshCore PR #2492, ver 12+): + * [cmd][0][16-byte key] = explicit scoped override + * [cmd][0] = clear scope override (use default_scope from prefs) + * [cmd][1] = explicit unscoped (sticky flag, bypasses default_scope) + */ + if (len >= 2 && data[1] == 0) { + if (len >= 2 + 16) { + memcpy(_send_scope.key, &data[2], sizeof(_send_scope.key)); + } else { + memset(_send_scope.key, 0, sizeof(_send_scope.key)); + } + _send_scope_force_unscoped = false; + writeOKFrame(); + } else if (len == 2 && data[1] == 1) { + _send_scope_force_unscoped = true; + memset(_send_scope.key, 0, sizeof(_send_scope.key)); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSetDefaultFloodScope(const uint8_t *data, size_t len) +{ + /* Set default flood scope: [cmd][name:31][key:16] or [cmd] alone (clear) */ + if (len >= 1 + 31 + 16) { + int n = strnlen((const char *)&data[1], 31); + if (n > 0 && n < 31) { + memset(prefs.default_scope_name, 0, sizeof(prefs.default_scope_name)); + memcpy(prefs.default_scope_name, &data[1], n); + memcpy(prefs.default_scope_key, &data[1 + 31], 16); + _store->savePrefs(prefs); + writeOKFrame(); + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + memset(prefs.default_scope_name, 0, sizeof(prefs.default_scope_name)); + memset(prefs.default_scope_key, 0, sizeof(prefs.default_scope_key)); + _store->savePrefs(prefs); + writeOKFrame(); + } + return true; +} + +bool CompanionMesh::handleCmdGetDefaultFloodScope(const uint8_t *data, size_t len) +{ + uint8_t rsp[1 + 31 + 16]; + rsp[0] = PACKET_DEFAULT_FLOOD_SCOPE; + if (strlen(prefs.default_scope_name) > 0) { + memcpy(&rsp[1], prefs.default_scope_name, 31); + memcpy(&rsp[1 + 31], prefs.default_scope_key, 16); + writeFrame(rsp, sizeof(rsp)); + } else { + writeFrame(rsp, 1); /* no name or key means null */ + } + return true; +} + +bool CompanionMesh::handleCmdSendControlData(const uint8_t *data, size_t len) +{ + /* Control data: [cmd][flags | 0x80][data...] */ + if (len >= 2 && (data[1] & 0x80) != 0) { + mesh::Packet *pkt = createControlData(&data[1], len - 1); + if (pkt) { + sendZeroHop(pkt); + writeOKFrame(); + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} + +bool CompanionMesh::handleCmdSetAutoaddConfig(const uint8_t *data, size_t len) +{ + if (len >= 2) { + prefs.autoadd_config = data[1]; + if (len >= 3) { + prefs.autoadd_max_hops = (data[2] > 64) ? 64 : data[2]; + } + LOG_INF("SET_AUTOADD_CONFIG: autoadd_config=0x%02x max_hops=%u", + prefs.autoadd_config, prefs.autoadd_max_hops); + _store->savePrefs(prefs); + } + writeOKFrame(); + return true; +} + +bool CompanionMesh::handleCmdGetAutoaddConfig(const uint8_t *data, size_t len) +{ + uint8_t rsp[3]; + rsp[0] = PACKET_AUTOADD_CONFIG; + rsp[1] = prefs.autoadd_config; + rsp[2] = prefs.autoadd_max_hops; + writeFrame(rsp, sizeof(rsp)); + return true; +} + +bool CompanionMesh::handleCmdGetAllowedRepeatFreq(const uint8_t *data, size_t len) +{ + uint8_t rsp[1 + sizeof(repeat_freq_ranges)]; + int i = 0; + rsp[i++] = PACKET_ALLOWED_REPEAT_FREQ; + for (size_t k = 0; k < sizeof(repeat_freq_ranges) / sizeof(repeat_freq_ranges[0]); k++) { + put_le32(&rsp[i], repeat_freq_ranges[k].lower_freq); i += 4; + put_le32(&rsp[i], repeat_freq_ranges[k].upper_freq); i += 4; + } + writeFrame(rsp, i); + return true; +} + +bool CompanionMesh::handleCmdSendRawPacket(const uint8_t *data, size_t len) +{ + if (len >= 4) { + mesh::Packet *pkt = obtainNewPacket(); + if (pkt) { + uint8_t priority = data[1]; + if (tryParsePacket(pkt, &data[2], len - 2)) { + sendPacket(pkt, priority, 0); + writeOKFrame(); + } else { + releasePacket(pkt); + writeErrFrame(ERR_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_TABLE_FULL); + } + } else { + writeErrFrame(ERR_ILLEGAL_ARG); + } + return true; +} diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 1568ebf..8a768c5 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: MIT - * CompanionMesh - ZephCore Companion device application layer + * CompanionMesh - the companion role (upstream: examples/companion_radio/MyMesh) */ #pragma once @@ -12,7 +12,7 @@ #include #include -/* BLE push notification codes */ +/* Push notification codes */ #define PUSH_CODE_ADVERT 0x80 #define PUSH_CODE_PATH_UPDATED 0x81 #define PUSH_CODE_SEND_CONFIRMED 0x82 @@ -31,6 +31,13 @@ #define PUSH_CODE_CONTACT_DELETED 0x8F #define PUSH_CODE_CONTACTS_FULL 0x90 +#define REQ_TYPE_GET_TELEMETRY_DATA 0x03 + +/* Telemetry permissions (upstream: helpers/SensorManager.h) */ +#define TELEM_PERM_BASE 0x01 +#define TELEM_PERM_LOCATION 0x02 +#define TELEM_PERM_ENVIRONMENT 0x04 + /* Auto-add config bitmask */ #define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) #define AUTO_ADD_CHAT (1 << 1) @@ -38,36 +45,33 @@ #define AUTO_ADD_ROOM_SERVER (1 << 3) #define AUTO_ADD_SENSOR (1 << 4) -/* Canonical definition is in ZephyrBLE.h; guard here for TUs that don't include it */ +/* Canonical definition is in ZephyrBLE.h */ #ifndef MAX_FRAME_SIZE #define MAX_FRAME_SIZE 176 #endif -/* 1 header + 32 pubkey + 1 type + 1 flags + 1 path_len + 64 path + 32 name + 4*4 fields = 148 */ +/* 1 header + 32 pubkey + 1 type + 1 flags + 1 path_len + 64 path + 32 name + 4*4 fields */ #define CONTACT_FRAME_SIZE 148 -/* Offline message queue depth */ #ifdef CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE #define OFFLINE_QUEUE_SIZE CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE #else #define OFFLINE_QUEUE_SIZE 16 #endif -/* Pending ACK tracking slots */ #ifdef CONFIG_ZEPHCORE_ACK_TABLE_SIZE #define ACK_TABLE_SIZE CONFIG_ZEPHCORE_ACK_TABLE_SIZE #else #define ACK_TABLE_SIZE 16 #endif -/* Recently-heard advert path slots */ #ifdef CONFIG_ZEPHCORE_ADVERT_PATH_TABLE_SIZE #define ADVERT_PATH_TABLE_SIZE CONFIG_ZEPHCORE_ADVERT_PATH_TABLE_SIZE #else #define ADVERT_PATH_TABLE_SIZE 16 #endif -/* Cached advert path entry */ +/* A recently heard advert's path */ struct AdvertPath { uint8_t pubkey_prefix[7]; uint8_t path_len; @@ -76,39 +80,26 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; -/* BLE push notification callback */ typedef void (*PushCallback)(uint8_t code, const uint8_t *data, size_t len); - -/* BLE write frame callback */ typedef size_t (*WriteFrameCallback)(const uint8_t *data, size_t len); - -/* Battery millivolt read callback */ typedef uint16_t (*GetBatteryCallback)(void); -/* Radio reconfigure callback. preset_changed = the channel or the modem - * config moved (freq/bw/sf), not just TX power — the adaptive-CAD state is - * tied to those and has to be reset with them (see main_companion). */ +/* preset_changed: freq/bw/sf moved, not just TX power; the adaptive-CAD state + * is tied to those and is reset with them. */ typedef void (*RadioReconfigureCallback)(bool preset_changed); -/* BLE PIN change callback */ typedef void (*PinChangeCallback)(uint32_t new_pin); -/* Companion CLI execution callback — runs a text-CLI line and fills `reply` - * (buffer is COMPANION_CLI_REPLY_SIZE). Registered by main_companion so every - * companion-side CLI entry point shares one CommonCLI instance: the USB text - * CLI, the v-contact chat, and CMD_RUN_CLI_COMMAND from the app. */ +/* Runs one CLI command for CompanionMesh::handleCommand (prefix already + * stripped). main_companion owns the CommonCLI behind it. sender_timestamp 0 + * means local, where the buffer is COMPANION_CLI_PREFIX_ROOM + + * COMPANION_CLI_REPLY_SIZE; a remote buffer is CLI_REMOTE_REPLY_SIZE. + * reply_hdr_used is what the reflected prefix took of it. */ #define COMPANION_CLI_REPLY_SIZE 256 -typedef void (*CompanionCLICallback)(const char *line, char *reply); +#define COMPANION_CLI_PREFIX_ROOM 3 +typedef void (*CompanionCLICallback)(const char *command, uint32_t sender_timestamp, + uint8_t reply_hdr_used, char *reply); -/** - * CompanionMesh: Application layer for ZephCore Companion device - * - * Extends BaseChatMesh with: - * - BLE protocol frame handling - * - Offline message queue - * - Push notifications for incoming messages/adverts - * - ACK tracking for sent messages - */ class CompanionMesh : public BaseChatMesh, public DataStoreHost { public: CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, @@ -120,125 +111,64 @@ public: int getOfflineQueueCount() const { return _offline_queue_count; } - /** - * Build and send a self advert using the configured location policy, - * path-hash mode, and default transport scope. This is the canonical - * path shared by the BLE/USB CMD_SEND_SELF_ADVERT handler and the UI - * (button / joystick) advert actions, so flood adverts always honor - * prefs.path_hash_mode and the region scope regardless of trigger. - * - * @param flood true for a flood advert, false for zero-hop. - * @return false if the packet pool was full (advert not sent). - */ + /* The one self-advert path (app command and UI buttons alike), so every + * advert honours the location policy, path-hash mode and default scope. + * False if the packet pool was full. */ bool sendSelfAdvert(bool flood) override; - /** - * Handle a protocol frame from BLE. - * Returns true if frame was handled. - */ - bool handleProtocolFrame(const uint8_t *data, size_t len); + /* One companion-protocol frame from the app; true if handled. */ + bool handleCmdFrame(const uint8_t *data, size_t len); - /** - * Set callback for BLE push notifications. - */ void setPushCallback(PushCallback cb) { _push_cb = cb; } - - /** - * Set callback for writing response frames to BLE. - */ void setWriteFrameCallback(WriteFrameCallback cb) { _write_cb = cb; } - - /** - * Set callback for getting battery voltage. - */ void setBatteryCallback(GetBatteryCallback cb) { _batt_cb = cb; } - - /** - * Set callback for radio reconfigure. - */ void setRadioReconfigureCallback(RadioReconfigureCallback cb) { _radio_reconfig_cb = cb; } - - /** - * Set callback for BLE PIN change. - */ void setPinChangeCallback(PinChangeCallback cb) { _pin_change_cb = cb; } - - /* ---- V-contact: loopback admin contact ("v") ---- - * A synthesized CHAT contact visible only to the connected BLE/USB app. - * Messages to it are short-circuited into the CLI before any packet is - * created — nothing ever reaches the dispatcher or the radio. Its pubkey - * is the Ed25519 point of a keypair seeded from - * SHA256("zc-vcontact" || self prv_key || counter) — a real point, because - * strict clients decompress peer keys and reject anything else. The private - * half is derived and dropped: never stored, never used. The key is never - * registered in the RF RX matching path, so over-the-air traffic addressed - * to it is inert. */ void setCLICallback(CompanionCLICallback cb) { _cli_exec_cb = cb; } + + /* The companion CLI (upstream MyMesh::handleCommand). Front-ends: the app's + * CMD_RUN_CLI_COMMAND, the USB text console and the v-contact chat (all + * local, sender_timestamp 0), and TXT_TYPE_CLI_COMMAND from a contact with + * flag 0x10. False for an unknown command. */ + bool handleCommand(const char *command, uint32_t sender_timestamp, char *reply); + + /* ---- V-contact ("v"), see app/VContact.cpp ---- + * A chat contact that exists only toward the connected app; messages to it + * run the CLI and never reach the radio. */ bool isVContactEnabled() const { return prefs.v_contact_enabled != 0; } - /** Queue an unsolicited v-contact message (battery alert, restart reason). - * Goes through the offline queue — delivered on next app connect/sync. */ + /* Unsolicited notice (battery alert, restart reason), via the offline queue. */ void vcontactNotify(const char *text); - /** Push the v-contact to a connected app as a NEW_ADVERT (call after - * runtime enable or rename). No-op push when nothing is connected. */ + /* Push it as NEW_ADVERT after a runtime enable or rename. */ void vcontactPushAdvert(); - /** Push CONTACT_DELETED for the v-contact (call after runtime disable). */ + /* Push CONTACT_DELETED after a runtime disable. */ void vcontactPushDeleted(); - /** Clock became (or may have become) valid — activate the v-contact if it - * was deferred (no more 1970 adverts) and flush buffered notices so they - * carry sane timestamps. Self-gating; safe to call speculatively. Hooked - * at CMD_APP_START, CMD_SET_DEVICE_TIME, and GPS time sync. */ + /* The clock may have become valid: activate a deferred v-contact and flush + * buffered notices. Self-gating, safe to call speculatively. */ void vcontactClockSynced(); - /** True while a v-contact delivery-ack is still waiting to be emitted. - * Reboot-class CLI commands gate on this (plus transport TX idle) so the - * ack is not cut off by the reset it just scheduled. */ + /* A delivery ack is still waiting; reboot-class commands wait for it. */ bool vcontactConfirmPending() const { return _vcontact_confirm_ack != 0; } - /** - * Continue contact iteration (call each main loop iteration). - * Returns true if contacts are still being sent. - */ + /* Streams the contact dump; call each loop. True while contacts remain. */ bool continueContactIteration(); - /** - * Cancel contact iteration silently (no frame sent). - * Call on BLE disconnect — there's nobody to send CONTACT_END to. - */ + /* On disconnect: nobody to send CONTACT_END to. */ void cancelContactIterator() { _contact_iter_active = false; } - /** True while a contact dump is in progress (drives the stall watchdog). */ + /* Dump state for the stall watchdog: it re-kicks only if the index stops. */ bool isContactIterActive() const { return _contact_iter_active; } - - /** Dump progress cursor — the watchdog re-kicks only if this stops moving. */ int getContactIterIdx() const { return _contact_iter_idx; } - /** - * Offline-queue stall watchdog — call from the housekeeping tick. - * - * A queued message reaches the app through exactly one best-effort - * PUSH_CODE_MSG_WAITING, which the app answers with CMD_SYNC_NEXT_MESSAGE. - * Nothing retried that push: if it was dropped (BLE congestion) or - * suppressed (_vcontact_hold_msgwait), or the app simply ignored it because - * it believed a sync was already running, the queue sat untouched until the - * next connect — the app showed a delivered message with no reply, then - * received the whole backlog at once on reconnect. The contact dump and - * advertising both already have a watchdog here; the message queue did not. - * - * MSG_WAITING is one of the three genuinely idempotent push codes (see - * zephcore_ble_send's overflow note), so a duplicate prompt costs nothing. - */ + /* Housekeeping tick: re-prompt MSG_WAITING when the offline queue has gone + * quiet. A queued message reaches the app through one best-effort prompt; + * if that was dropped, held, or ignored, the queue sat until reconnect. + * MSG_WAITING is idempotent, so a duplicate costs nothing. */ void msgWaitingWatchdog(); - /** - * Cancel pending message sync. Un-ACKed message stays in queue. - * Call on BLE disconnect so the message is re-sent on reconnect. - */ + /* On disconnect: the un-ACKed message stays queued and is re-sent. */ void cancelSyncPending() { _sync_pending = false; } - /** - * Free the Ed25519 signing buffer if allocated. - * Call on BLE disconnect to prevent 8KB leak when disconnect - * occurs between CMD_SIGN_START and CMD_SIGN_FINISH. - */ + /* On disconnect: free the 8 KB sign buffer a session may have abandoned + * between CMD_SIGN_START and CMD_SIGN_FINISH. */ void cleanupSignState() { if (_sign_data) { delete[] _sign_data; @@ -248,38 +178,18 @@ public: _sign_data_capacity = 0; } - /** - * Get BLE device name for advertising. - */ const char *getDeviceName() const { return prefs.node_name[0] ? prefs.node_name : nullptr; } - /** - * Get recently heard advert paths. - */ int getRecentlyHeard(AdvertPath dest[], int max_num); - - /** - * Find advert path by pubkey prefix. - */ const AdvertPath *findAdvertPath(const uint8_t *pubkey_prefix, int prefix_len); - /** - * Queue a locally-originated DM into the BLE offline queue and signal - * MSG_WAITING. The frame uses path_len = OUT_PATH_SENT (0xFE) and the - * body is prefixed with "(>>✓) " on delivery or "(>>✗) " on failure so - * the phone app shows a visible outcome indicator without needing - * protocol-level support. - */ + /* A DM sent from the device UI, queued for the app with path_len + * OUT_PATH_SENT and a "(>>✓) "/"(>>✗) " delivery marker. */ void queueLocalSentContactMessage(const ContactInfo &contact, uint32_t timestamp, const char *text, bool delivered); - /** - * Queue a locally-originated channel message into the BLE offline queue - * and signal MSG_WAITING. The body is rendered as - * " : " — heard_repeat picks - * "(>>✓) " (at least one neighbor repeated the flood) vs "(>>✗) " - * (no repeats heard within the joystick UI's feedback window). - */ + /* A channel message sent from the device UI, queued for the app as + * " : "; the marker says whether a repeat was heard. */ void queueLocalSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char *text, bool heard_repeat); @@ -292,20 +202,20 @@ public: /* Mesh time sync */ MeshTimeSync *getMeshTimeSync() { return &_timesync; } void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } - /* Paced evaluation — called from the housekeeping event (loop() only runs - * on packet-driven events). */ + /* From the housekeeping event: loop() only runs on packet events. */ void timeSyncTick(); - /* Prefs (includes node_lat/lon) */ NodePrefs prefs; protected: - /* BaseChatMesh virtual implementations */ + /* BaseChatMesh */ void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t *path) override; ContactInfo *processAck(const uint8_t *data) override; void onContactPathUpdated(const ContactInfo &contact) override; void onMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) override; void onCommandDataRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) override; + void onCLICommandRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, + const char *text, char *reply) override; void onSignedMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override; uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override; uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override; @@ -316,40 +226,34 @@ protected: uint8_t onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data, uint8_t len, uint8_t *reply) override; void onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) override; - /* Raw packet logging for app RX log */ + /* Raw packet logging for the app's RX log */ void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; void logRx(mesh::Packet *pkt, int len, float score) override; void logTx(mesh::Packet *pkt, int len) override; - /* Trace path response */ void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; - - /* Control data response (repeater discovery, etc) */ void onControlDataRecv(mesh::Packet *packet) override; - - /* Raw data response (custom packets) */ void onRawDataRecv(mesh::Packet *packet) override; - /* Packet forwarding (client repeat / offgrid mode) */ + /* Client repeat / off-grid forwarding */ bool allowPacketForward(const mesh::Packet *packet) override; - /* Path discovery - intercept path data before base class strips it */ + /* Path discovery: sees the path data before the base class strips it */ bool onContactPathRecv(ContactInfo &from, uint8_t *in_path, uint8_t in_path_len, uint8_t *out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) override; - /* Flood scope - scoped sending for region filtering */ + /* Region-scoped flooding */ void sendFloodScoped(const TransportKey &scope, mesh::Packet *pkt, uint32_t delay_millis); void sendFloodScoped(const ContactInfo &recipient, mesh::Packet *pkt, uint32_t delay_millis = 0) override; void sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override; - /* Dispatcher tuning (uses prefs) */ uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; - /* Companion initial flood jitter is fixed-window; passive flood - * tracking is not needed unless forwarding is enabled. */ + /* Fixed-window initial jitter; passive flood tracking only matters when + * forwarding. */ bool passivelyTrackFloods() const override { return false; } uint32_t getInitialFloodJitter(const mesh::Packet *packet) override; @@ -364,7 +268,7 @@ protected: } } - /* Auto-add filtering overrides */ + /* Auto-add filtering */ bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -372,7 +276,6 @@ protected: void onContactsFull() override; void onContactOverwrite(const uint8_t *pub_key) override; - /* Storage overrides */ int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override; bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override; @@ -384,17 +287,16 @@ private: RadioReconfigureCallback _radio_reconfig_cb; PinChangeCallback _pin_change_cb; - /* Contact iteration state */ + /* Contact dump. The bound and the v-contact's inclusion are snapshotted at + * PACKET_CONTACT_START, so the dump never streams more than the total it + * announced: the table grows mid-dump, and the v-contact appears the moment + * a cold-booted clock becomes valid. */ bool _contact_iter_active; int _contact_iter_idx; - /* Table bound and v-contact inclusion are snapshotted at PACKET_CONTACT_START - * so the dump can never stream more entries than the total it promised: the - * table grows from inbound adverts mid-dump, and vcontactReady() flips false - * ->true the moment a cold-booted clock goes valid (CMD_SET_DEVICE_TIME). */ int _contact_iter_num; bool _contact_iter_vc; uint32_t _contact_iter_lastmod; - uint32_t _contact_iter_since; /* Filter: only send contacts with lastmod > this */ + uint32_t _contact_iter_since; /* only contacts with lastmod > this */ /* Offline message queue */ struct QueuedFrame { @@ -405,9 +307,8 @@ private: int _offline_queue_head; int _offline_queue_tail; int _offline_queue_count; - bool _sync_pending; /* true = last peeked message not yet ACKed by phone */ + bool _sync_pending; /* the last peeked message is not yet ACKed by the app */ - /* ACK tracking table */ struct AckEntry { uint32_t expected_ack; uint32_t sent_time; @@ -416,70 +317,55 @@ private: }; AckEntry _ack_table[ACK_TABLE_SIZE]; - /* Advert path table for tracking recently heard nodes */ AdvertPath _advert_paths[ADVERT_PATH_TABLE_SIZE]; - /* Signing state */ + /* CMD_SIGN_* state */ uint8_t *_sign_data; uint32_t _sign_data_len; uint32_t _sign_data_capacity; - /* Pending request tracking (for response matching) */ + /* Pending requests, for matching responses */ uint32_t _pending_login; uint32_t _pending_status; uint32_t _pending_telemetry; uint32_t _pending_discovery; uint32_t _pending_req; #ifdef CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK - uint32_t _pending_joystick_ping_tag; /* tag-based match, checked before pubkey-based _pending_status */ - uint32_t _pending_joystick_admin_tag; /* same protection for admin binary requests */ + uint32_t _pending_joystick_ping_tag; /* matched by tag, before _pending_status */ + uint32_t _pending_joystick_admin_tag; /* same, for admin binary requests */ #endif - /* Lazy contacts/channels write - batches rapid updates */ + /* Lazy contacts/channels writes */ int64_t _dirty_contacts_expiry; int64_t _dirty_channels_expiry; - static constexpr int64_t LAZY_WRITE_DELAY_MS = 5000; /* 5 seconds, matches Arduino */ + static constexpr int64_t LAZY_WRITE_DELAY_MS = 5000; /* as upstream */ - /* Deadline for liveness-only contact updates — a re-heard advert from a - * contact we already know, where the only fields that moved are - * last_advert_timestamp and lastmod. - * - * saveContacts() rewrites the WHOLE file (fixed 152-byte records, no - * incremental path), which on a board without external flash is ~47 KB - * into a 128 KB LittleFS partition. Measured on a T1000-E 2026-08-24: - * 21 full rewrites in 90 minutes, one per advert arrival, because every - * re-advert marked the file dirty on the same 5 s deadline as a real - * change. That is a flash-wear problem on a battery tracker. - * - * Liveness still persists — it just waits, so an hour of re-adverts - * costs one write instead of fourteen. A substantive change (new - * contact, message, path update) still pulls the deadline back in. */ + /* Deadline for a liveness-only change (a known contact re-advertised). + * saveContacts() rewrites the whole file (~47 KB without external flash); + * on the 5 s deadline a T1000-E did 21 rewrites in 90 minutes, one per + * advert. A substantive change still pulls the deadline in. */ static constexpr int64_t LAZY_WRITE_LIVENESS_MS = 600000; /* 10 minutes */ - void onLoginSent(const ContactInfo &contact) override; - void onChannelAdded(ChannelDetails *ch) override; void markContactsDirty(bool substantive = true); void markChannelsDirty(); void flushDirtyContacts(); void flushDirtyChannels(); - /* Mesh time sync (forward-only: our clock stamps outgoing DMs and peers - * hold per-sender replay high-water marks) */ + /* Forward-only: our clock stamps outgoing DMs, and peers keep per-sender + * replay high-water marks. */ MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; void onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, uint8_t hops) override; - /* Protocol version negotiation */ uint8_t _app_target_ver; /* Flood scope for transport filtering (all zeros = disabled) */ TransportKey _send_scope; bool _send_scope_force_unscoped; + /* The joystick ping tag is independent of app requests and not cleared. */ void clearPendingReqs() { _pending_login = _pending_status = _pending_telemetry = _pending_discovery = _pending_req = 0; - /* _pending_joystick_ping_tag is intentionally NOT cleared here - * joystick ping is independent of BLE request state. */ } #ifdef CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK @@ -488,129 +374,147 @@ public: void clearJoystickPingTag() { _pending_joystick_ping_tag = 0; } void setJoystickAdminTag(uint32_t tag) { _pending_joystick_admin_tag = tag; } void clearJoystickAdminTag() { _pending_joystick_admin_tag = 0; } - /* Force a contacts-flush schedule from outside (joystick UI clears a - * stale out_path_len during 5th-attempt fallback flood and needs the - * change to persist to /ext/contacts3). */ + /* The joystick UI clears a stale out_path_len on its fallback flood. */ void markContactsDirtyPublic() { markContactsDirty(); } private: #endif bool writeFrame(const uint8_t *data, size_t len); - void sendPacketOk(); - void sendPacketError(uint8_t code); - /* Emit the PACKET_SENT response: [PACKET_SENT][is_flood][tag:4][est_timeout:4]. */ + /* Companion protocol opcode handlers, in upstream handleCmdFrame order */ + bool handleCmdDeviceQuery(const uint8_t *data, size_t len); + bool handleCmdAppStart(const uint8_t *data, size_t len); + bool handleCmdRunCliCommand(const uint8_t *data, size_t len); + bool handleCmdSendTxtMsg(const uint8_t *data, size_t len); + bool handleCmdSendChannelTxtMsg(const uint8_t *data, size_t len); + bool handleCmdSendChannelData(const uint8_t *data, size_t len); + bool handleCmdGetContacts(const uint8_t *data, size_t len); + bool handleCmdSetAdvertName(const uint8_t *data, size_t len); + bool handleCmdSetAdvertLatlon(const uint8_t *data, size_t len); + bool handleCmdGetDeviceTime(const uint8_t *data, size_t len); + bool handleCmdSetDeviceTime(const uint8_t *data, size_t len); + bool handleCmdSendSelfAdvert(const uint8_t *data, size_t len); + bool handleCmdResetPath(const uint8_t *data, size_t len); + bool handleCmdAddUpdateContact(const uint8_t *data, size_t len); + bool handleCmdRemoveContact(const uint8_t *data, size_t len); + bool handleCmdShareContact(const uint8_t *data, size_t len); + bool handleCmdGetContactByKey(const uint8_t *data, size_t len); + bool handleCmdExportContact(const uint8_t *data, size_t len); + bool handleCmdImportContact(const uint8_t *data, size_t len); + bool handleCmdSyncNextMessage(const uint8_t *data, size_t len); + bool handleCmdSetRadioParams(const uint8_t *data, size_t len); + bool handleCmdSetRadioTxPower(const uint8_t *data, size_t len); + bool handleCmdSetTuningParams(const uint8_t *data, size_t len); + bool handleCmdGetTuningParams(const uint8_t *data, size_t len); + bool handleCmdSetOtherParams(const uint8_t *data, size_t len); + bool handleCmdSetPathHashMode(const uint8_t *data, size_t len); + bool handleCmdReboot(const uint8_t *data, size_t len); + bool handleCmdGetBattAndStorage(const uint8_t *data, size_t len); + bool handleCmdExportPrivateKey(const uint8_t *data, size_t len); + bool handleCmdImportPrivateKey(const uint8_t *data, size_t len); + bool handleCmdSendRawData(const uint8_t *data, size_t len); + bool handleCmdSendLogin(const uint8_t *data, size_t len); + bool handleCmdSendAnonReq(const uint8_t *data, size_t len); + bool handleCmdSendStatusReq(const uint8_t *data, size_t len); + bool handleCmdSendPathDiscoveryReq(const uint8_t *data, size_t len); + bool handleCmdSendTelemetryReq(const uint8_t *data, size_t len); + bool handleCmdSendBinaryReq(const uint8_t *data, size_t len); + bool handleCmdHasConnection(const uint8_t *data, size_t len); + bool handleCmdLogout(const uint8_t *data, size_t len); + bool handleCmdGetChannel(const uint8_t *data, size_t len); + bool handleCmdSetChannel(const uint8_t *data, size_t len); + bool handleCmdSignStart(const uint8_t *data, size_t len); + bool handleCmdSignData(const uint8_t *data, size_t len); + bool handleCmdSignFinish(const uint8_t *data, size_t len); + bool handleCmdSendTracePath(const uint8_t *data, size_t len); + bool handleCmdSetDevicePin(const uint8_t *data, size_t len); + bool handleCmdGetCustomVars(const uint8_t *data, size_t len); + bool handleCmdSetCustomVar(const uint8_t *data, size_t len); + bool handleCmdGetAdvertPath(const uint8_t *data, size_t len); + bool handleCmdGetStats(const uint8_t *data, size_t len); + bool handleCmdFactoryReset(const uint8_t *data, size_t len); + bool handleCmdSetFloodScopeKey(const uint8_t *data, size_t len); + bool handleCmdSetDefaultFloodScope(const uint8_t *data, size_t len); + bool handleCmdGetDefaultFloodScope(const uint8_t *data, size_t len); + bool handleCmdSendControlData(const uint8_t *data, size_t len); + bool handleCmdSetAutoaddConfig(const uint8_t *data, size_t len); + bool handleCmdGetAutoaddConfig(const uint8_t *data, size_t len); + bool handleCmdGetAllowedRepeatFreq(const uint8_t *data, size_t len); + bool handleCmdSendRawPacket(const uint8_t *data, size_t len); + + void writeOKFrame(); + void writeErrFrame(uint8_t code); + /* [PACKET_SENT][is_flood][tag:4][est_timeout:4] */ void sendPacketSent(uint8_t result, uint32_t tag, uint32_t est_timeout); void sendPush(uint8_t code, const uint8_t *data = nullptr, size_t len = 0); - /* Shared body for the recipient/channel sendFloodScoped overloads — both - * resolve to the same default-scope logic (see the TODOs at each site). */ + /* The default-scope body shared by the recipient and channel overloads */ void sendFloodScopedDefault(mesh::Packet *pkt, uint32_t delay_millis); - /* Append self-telemetry as Cayenne LPP into `out`, returning bytes written. - * `permissions` gates the LOCATION and ENVIRONMENT sections (battery is - * always included); pass all TELEM_PERM_* bits for unconditional output. */ + /* Self telemetry as Cayenne LPP; bytes written. Battery is always included, + * `permissions` gates location and environment. */ int appendSelfTelemetry(uint8_t *out, uint8_t permissions); - /** Serialize a ContactInfo into buf. If header != 0, prepend it. - * Returns total bytes written. buf must be >= CONTACT_FRAME_SIZE. */ + /* header != 0 is prepended; buf must hold CONTACT_FRAME_SIZE. */ static size_t serializeContact(uint8_t *buf, const ContactInfo &c, uint8_t header = 0); - void queueOfflineMessage(const uint8_t *data, size_t len); - bool dequeueOfflineMessage(uint8_t *dest, size_t &len); + void addToOfflineQueue(const uint8_t *data, size_t len); bool peekOfflineMessage(uint8_t *dest, size_t &len); void confirmOfflineMessage(); bool enqueuePendingChannelInfo(uint8_t idx); bool sendChannelInfoFrame(uint8_t idx); void drainPendingChannelInfos(); - void queueContactMessage(const ContactInfo &contact, mesh::Packet *pkt, - uint8_t txt_type, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text); + void queueMessage(const ContactInfo &contact, uint8_t txt_type, mesh::Packet *pkt, + uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text); - /* V-contact internals. _vcontact_lastmod == 0 means "not yet activated": - * the clock was invalid (pre-1970s epoch) when we would have stamped it, - * so the contact is withheld from sync/adverts until a time source - * arrives — otherwise the app shows a 1970 last-heard timestamp. */ CompanionCLICallback _cli_exec_cb; + + /* ---- V-contact state (app/VContact.cpp) ---- */ + /* _vcontact_lastmod == 0: not activated yet. The clock was invalid when it + * would have been stamped, so it is withheld rather than shown as 1970. */ uint8_t _vcontact_pubkey[PUB_KEY_SIZE]; uint32_t _vcontact_lastmod; - /* Dedupe app resends: a retry reuses the message timestamp (only the - * attempt byte changes). The retry lands several seconds later, after other - * messages, so a single last-seen slot misses it — track a ring of recent - * timestamps instead. */ + /* App resends reuse the message timestamp and can land seconds later, + * behind other messages, hence a ring rather than one last-seen slot. */ static const uint8_t VCONTACT_DEDUP_SLOTS = 16; uint32_t _vcontact_recent_ts[VCONTACT_DEDUP_SLOTS]; uint8_t _vcontact_recent_head; - char _vcontact_pending[2][128]; /* notices buffered while the clock is invalid */ + char _vcontact_pending[2][128]; /* notices held while the clock is invalid */ uint8_t _vcontact_pending_count; - /* Suppress a v-contact notice's MSG_WAITING push during the app's initial - * sync. Sent before/mid sync (e.g. the reboot-cause notice flushed at - * CMD_SET_DEVICE_TIME) it makes the app interleave message-sync into the - * contact stream, which trips the "reset iterator on any other command" - * guard and truncates the sync. Held from CMD_APP_START until the first - * PACKET_NO_MORE_MSGS (end of the contacts+messages initial sync). - * - * Bounded by _vcontact_hold_expiry. PACKET_NO_MORE_MSGS used to be the only - * exit, and the app is not obliged to produce it: a session that set the - * latch and then never message-synced to empty kept it forever, so every - * later CLI reply, battery alert and notice queued silently and the user - * saw the backlog only on reconnect. Confirmed on hardware — the app - * re-issues CMD_APP_START when its device-settings screen opens, about 4 s - * before the GPS toggle that appeared to cause it, on a connection already - * hours old and long past its initial sync. - * - * The real hazard (a prompt landing mid contact-dump) is checked directly - * in vcontactMsgWaitHeld(), so this latch is now only the CMD_APP_START -> - * CMD_GET_CONTACTS handoff guard and its deadline is sized accordingly. - * See VCONTACT_HOLD_MAX_MS. */ + /* Holds a notice's MSG_WAITING from CMD_APP_START until CMD_GET_CONTACTS: + * a prompt there makes the app interleave a message sync into the contact + * stream. Bounded by _vcontact_hold_expiry, because the app need not ever + * sync to empty; the mid-dump case is checked directly in + * vcontactMsgWaitHeld(). See VCONTACT_HOLD_MAX_MS. */ bool _vcontact_hold_msgwait; - int64_t _vcontact_hold_expiry; /* _ms->getMillis() deadline for the latch */ - /** Read the latch, expiring it first. Use instead of touching the flag. */ + int64_t _vcontact_hold_expiry; /* _ms->getMillis() deadline */ + /* Reads the latch, expiring it first; use instead of the flag. */ bool vcontactMsgWaitHeld(); - /* Offline-queue stall watchdog state, both _ms->getMillis(). The watchdog - * re-prompts only once the later of the two has gone quiet. */ + /* Offline-queue watchdog: it re-prompts once the later of these is quiet. */ int64_t _last_msgwait_ms; /* last PUSH_CODE_MSG_WAITING we emitted */ - int64_t _last_sync_req_ms; /* last CMD_SYNC_NEXT_MESSAGE the app sent */ - /** Emit PUSH_CODE_MSG_WAITING and stamp the watchdog. Every prompt goes - * through here so the watchdog cannot fire on top of a live sync. */ + int64_t _last_sync_req_ms; /* last CMD_SYNC_NEXT_MESSAGE from the app */ + /* Every MSG_WAITING goes through here, so the watchdog never fires over a + * live sync. */ void pushMsgWaiting(); - /* App-side delete (CMD_REMOVE_CONTACT for the loopback key) hides the - * v-contact for the rest of the session only — it deliberately does NOT - * touch prefs.v_contact_enabled. The v-contact is an ordinary entry in the - * app's contact list, so a "purge all contacts" walks it like any other and - * used to permanently disable a firmware feature with no way back except - * the USB CLI. The pref is node-side state: `set v.contact off` is the only - * durable disable. Cleared at CMD_APP_START (the session reset). */ + /* An app-side delete hides the v-contact for the session only: a "purge + * all contacts" walks it like any other entry. `set v.contact off` is the + * durable disable. Cleared at CMD_APP_START. */ bool _vcontact_app_hidden; - /* Deferred delivery-ack for a v-contact chat message. - * - * The ack itself is computed and PACKET_SENT emitted synchronously (the app - * is waiting on that as the response to its own write), but the - * PUSH_CODE_SEND_CONFIRMED that marks the bubble delivered is held back. - * Emitted inline it lands sub-millisecond after the response to the very - * same write -- before the app has committed the outgoing message to its - * own state -- and the app drops it. It then resends at est_timeout, the - * dedup ring above suppresses the CLI re-run, and the resend's ack is the - * one that finally sticks: one reply, two acks, delivery mark seconds late. - * - * No radio ack can arrive that fast, so the real send path never provokes - * this; the v-contact is the only sub-millisecond acker in the system. - * - * 0 = nothing pending. Emitted by the timer only — see the note on - * VCONTACT_CONFIRM_DELAY_MS for why an inbound frame is not the trigger. */ + /* Deferred SEND_CONFIRMED for a v-contact message (0 = none). Emitted + * inline it lands sub-millisecond after the response to the same write, + * before the app has committed the message, and the app drops it; the + * resend's ack then sticks, seconds late. See VCONTACT_CONFIRM_DELAY_MS. */ uint32_t _vcontact_confirm_ack; - /* CONTAINER_OF is offsetof underneath, and offsetof on a non-standard-layout - * type is only conditionally supported — CompanionMesh has base classes and - * virtuals, so it does not qualify (CommonCLI does, which is why the same - * trick is clean there). Wrap the work item in a POD that carries its own - * back-pointer: offsetof stays inside a standard-layout struct, and the - * owner comes from the pointer rather than from pointer arithmetic. */ + /* CONTAINER_OF relies on offsetof, which is only conditionally supported on + * a non-standard-layout type like CompanionMesh; this POD carries its own + * back-pointer instead. */ struct ConfirmWork { struct k_work_delayable work; CompanionMesh *self; }; ConfirmWork _vcontact_confirm_work; static void vcontactConfirmWorkHandler(struct k_work *work); - /** Emit a deferred SEND_CONFIRMED now, if one is pending. Idempotent. */ + /* Emit a pending SEND_CONFIRMED now. Idempotent. */ void vcontactFlushConfirm(); bool vcontactClockValid(); bool vcontactReady() { @@ -618,13 +522,11 @@ private: } void buildVContact(ContactInfo &c) const; bool isVContactKey(const uint8_t *key, int prefix_len) const; - /** (Re)derive _vcontact_pubkey from the current identity. Call on boot and - * whenever the identity changes (CMD_IMPORT_PRIVATE_KEY). */ + /* On boot and whenever the identity changes (CMD_IMPORT_PRIVATE_KEY). */ void deriveVContactKey(); - /** Intercept protocol frames addressed to the v-contact. Returns true when - * the frame was fully handled (response already written). */ + /* True when a frame addressed to the v-contact was fully handled. */ bool vcontactHandleFrame(const uint8_t *data, size_t len); - /** Chunk `text` into offline-queue contact messages from the v-contact. */ + /* Chunks `text` into v-contact messages on the offline queue. */ void vcontactQueueText(const char *text); void addPendingAck(uint32_t expected, int contact_idx); diff --git a/zephcore/app/CompanionProtocol.h b/zephcore/app/CompanionProtocol.h new file mode 100644 index 0000000..5046bff --- /dev/null +++ b/zephcore/app/CompanionProtocol.h @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: MIT + * Companion protocol: opcodes, response and error codes (upstream keeps these + * at the top of examples/companion_radio/MyMesh.cpp). + */ + +#pragma once + +#include + +/* Protocol commands (matches Arduino companion_radio) - sorted by opcode */ +#define CMD_APP_START 0x01 +#define CMD_SEND_TXT_MSG 0x02 +#define CMD_SEND_CHANNEL_TXT_MSG 0x03 +#define CMD_GET_CONTACTS 0x04 +#define CMD_GET_DEVICE_TIME 0x05 +#define CMD_SET_DEVICE_TIME 0x06 +#define CMD_SEND_SELF_ADVERT 0x07 +#define CMD_SET_ADVERT_NAME 0x08 +#define CMD_ADD_UPDATE_CONTACT 0x09 +#define CMD_SYNC_NEXT_MESSAGE 0x0A +#define CMD_SET_RADIO_PARAMS 0x0B +#define CMD_SET_RADIO_TX_POWER 0x0C +#define CMD_RESET_PATH 0x0D +#define CMD_SET_ADVERT_LATLON 0x0E +#define CMD_REMOVE_CONTACT 0x0F +#define CMD_SHARE_CONTACT 0x10 +#define CMD_EXPORT_CONTACT 0x11 +#define CMD_IMPORT_CONTACT 0x12 +#define CMD_REBOOT 0x13 +#define CMD_GET_BATT_AND_STORAGE 0x14 +#define CMD_SET_TUNING_PARAMS 0x15 +#define CMD_DEVICE_QUERY 0x16 +#define CMD_EXPORT_PRIVATE_KEY 0x17 +#define CMD_IMPORT_PRIVATE_KEY 0x18 +#define CMD_SEND_RAW_DATA 0x19 +#define CMD_SEND_LOGIN 0x1A +#define CMD_SEND_STATUS_REQ 0x1B +#define CMD_HAS_CONNECTION 0x1C +#define CMD_LOGOUT 0x1D +#define CMD_GET_CONTACT_BY_KEY 0x1E +#define CMD_GET_CHANNEL 0x1F +#define CMD_SET_CHANNEL 0x20 +#define CMD_SIGN_START 0x21 +#define CMD_SIGN_DATA 0x22 +#define CMD_SIGN_FINISH 0x23 +#define CMD_SEND_TRACE_PATH 0x24 +#define CMD_SET_DEVICE_PIN 0x25 +#define CMD_SET_OTHER_PARAMS 0x26 +#define CMD_SEND_TELEMETRY_REQ 0x27 +#define CMD_GET_CUSTOM_VARS 0x28 +#define CMD_SET_CUSTOM_VAR 0x29 +#define CMD_GET_ADVERT_PATH 0x2A +#define CMD_GET_TUNING_PARAMS 0x2B +#define CMD_SEND_BINARY_REQ 0x32 +#define CMD_FACTORY_RESET 0x33 +#define CMD_SEND_PATH_DISCOVERY_REQ 0x34 +#define CMD_SET_FLOOD_SCOPE_KEY 0x36 /* v8+ (renamed from CMD_SET_FLOOD_SCOPE) */ +#define CMD_SEND_CONTROL_DATA 0x37 +#define CMD_GET_STATS 0x38 +#define CMD_SEND_ANON_REQ 0x39 +#define CMD_SET_AUTOADD_CONFIG 0x3A +#define CMD_GET_AUTOADD_CONFIG 0x3B +#define CMD_GET_ALLOWED_REPEAT_FREQ 0x3C +#define CMD_SET_PATH_HASH_MODE 0x3D +#define CMD_SEND_CHANNEL_DATA 0x3E +#define CMD_SET_DEFAULT_FLOOD_SCOPE 0x3F /* v11+ */ +#define CMD_GET_DEFAULT_FLOOD_SCOPE 0x40 /* v11+ */ +#define CMD_SEND_RAW_PACKET 0x41 /* v12+ */ +#define CMD_RUN_CLI_COMMAND 0x42 /* v14+ */ + +/* Response packet types */ +#define PACKET_OK 0x00 +#define PACKET_ERROR 0x01 +#define PACKET_CONTACT_START 0x02 +#define PACKET_CONTACT 0x03 +#define PACKET_CONTACT_END 0x04 +#define PACKET_SELF_INFO 0x05 +#define PACKET_SENT 0x06 +#define PACKET_CONTACT_MSG_RECV 0x07 /* Legacy - ver < 3 */ +#define PACKET_CHANNEL_MSG_RECV 0x08 /* Legacy - ver < 3 */ +#define PACKET_CURR_TIME 0x09 +#define PACKET_NO_MORE_MSGS 0x0A +#define PACKET_EXPORT_CONTACT 0x0B +#define PACKET_BATTERY 0x0C +#define PACKET_DEVICE_INFO 0x0D +#define PACKET_PRIVATE_KEY 0x0E +#define PACKET_DISABLED 0x0F +#define PACKET_CONTACT_MSG_V3 0x10 /* Contact message for app ver >= 3 */ +#define PACKET_CHANNEL_MSG_V3 0x11 /* Channel message for app ver >= 3 */ +#define PACKET_CHANNEL_INFO 0x12 +#define PACKET_SIGN_START 0x13 +#define PACKET_SIGNATURE 0x14 +#define PACKET_CUSTOM_VARS 0x15 +#define PACKET_ADVERT_PATH 0x16 +#define PACKET_TUNING_PARAMS 0x17 +#define PACKET_STATS 0x18 +#define PACKET_AUTOADD_CONFIG 0x19 +#define PACKET_ALLOWED_REPEAT_FREQ 0x1A +#define PACKET_CHANNEL_DATA_RECV 0x1B +#define PACKET_DEFAULT_FLOOD_SCOPE 0x1C +#define PACKET_CLI_REPLY 0x1D /* v14+, reply to CMD_RUN_CLI_COMMAND */ + +#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9) + +#define MAX_SIGN_DATA_LEN (8 * 1024) + +/* Error codes */ +#define ERR_UNSUPPORTED 0x01 +#define ERR_NOT_FOUND 0x02 +#define ERR_TABLE_FULL 0x03 +#define ERR_BAD_STATE 0x04 +#define ERR_ILLEGAL_ARG 0x06 + +/* Max LoRa TX power (dBm) for SX1262 */ +#define MAX_LORA_TX_POWER 22 + +/* Helper to put little-endian values */ +static inline void put_le16(uint8_t *p, uint16_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; } +static inline void put_le32(uint8_t *p, uint32_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; p[2] = (v >> 16) & 0xFF; p[3] = (v >> 24) & 0xFF; } +static inline uint32_t get_le32(const uint8_t *p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } diff --git a/zephcore/app/VContact.cpp b/zephcore/app/VContact.cpp new file mode 100644 index 0000000..15e7722 --- /dev/null +++ b/zephcore/app/VContact.cpp @@ -0,0 +1,401 @@ +/* + * SPDX-License-Identifier: MIT + * The v-contact ("v"): a chat contact that exists only toward the + * connected app. Messages to it run the companion CLI; replies and notices + * (battery alert, restart reason) come back as its messages via the offline + * queue. Invariants: + * - it never enters the contacts table, so never the RF RX matching path; + * - it is intercepted before any send path, so no packet is ever created; + * - nobody holds its private key, so over-the-air traffic to it is inert. + */ + +#include "CompanionMesh.h" +#include "CompanionProtocol.h" +#include +#include +#include +#include + +#include +LOG_MODULE_DECLARE(zephcore_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +void CompanionMesh::buildVContact(ContactInfo &c) const +{ + memcpy(c.id.pub_key, _vcontact_pubkey, PUB_KEY_SIZE); + c.type = ADV_TYPE_CHAT; + /* The app owns the flags (favourite, telemetry permissions); with no table + * record they live in prefs. Echoing 0 cleared the favourite on every sync. */ + c.flags = prefs.v_contact_flags; + c.out_path_len = 0; /* zero-hop direct — renders as "0 hops" in the app */ + c.shared_secret_valid = false; + memset(c.out_path, 0, sizeof(c.out_path)); + c.name[0] = 'v'; + StrHelper::strzcpy(&c.name[1], prefs.node_name, sizeof(c.name) - 1); + c.last_advert_timestamp = _vcontact_lastmod; + c.lastmod = _vcontact_lastmod; + c.gps_lat = 0; + c.gps_lon = 0; + c.sync_since = 0; +} + +bool CompanionMesh::isVContactKey(const uint8_t *key, int prefix_len) const +{ + if (!isVContactEnabled()) return false; + if (prefix_len > PUB_KEY_SIZE) prefix_len = PUB_KEY_SIZE; + return memcmp(key, _vcontact_pubkey, prefix_len) == 0; +} + +bool CompanionMesh::vcontactClockValid() +{ + /* Anything before the firmware build epoch is a never-synced clock. */ + return (uint32_t)getRTCClock()->getCurrentTime() >= (uint32_t)FIRMWARE_BUILD_EPOCH; +} + +void CompanionMesh::vcontactClockSynced() +{ + if (!isVContactEnabled() || !vcontactClockValid()) { + return; + } + if (_vcontact_lastmod == 0) { + /* Deferred activation: first valid time source — stamp and announce. + * From here the contact also appears in CMD_GET_CONTACTS syncs. */ + vcontactPushAdvert(); + } + /* Flush the notices held for a valid clock, now with real timestamps. */ + for (uint8_t i = 0; i < _vcontact_pending_count; i++) { + vcontactQueueText(_vcontact_pending[i]); + } + _vcontact_pending_count = 0; +} + +void CompanionMesh::vcontactQueueText(const char *text) +{ + /* A queued frame caps at 172 bytes and the V3 header takes 16: split into + * <=150-char messages, preferring line breaks. getCurrentTimeUnique() keeps + * their timestamps increasing so the app orders them. */ + static const size_t CHUNK_MAX = 150; + ContactInfo vc; + buildVContact(vc); + + const char *p = text; + size_t remaining = strlen(text); + while (remaining > 0) { + size_t take = remaining; + if (take > CHUNK_MAX) { + take = CHUNK_MAX; + for (size_t i = take; i > CHUNK_MAX / 2; i--) { + if (p[i - 1] == '\n') { take = i; break; } + } + } + char chunk[CHUNK_MAX + 1]; + memcpy(chunk, p, take); + chunk[take] = '\0'; + size_t adv = take; + for (size_t i = 0; i < take; i++) { + if (chunk[i] == '\r') chunk[i] = ' '; /* CRLF CLI output → LF */ + } + while (take > 0 && (chunk[take - 1] == '\n' || chunk[take - 1] == ' ')) { + chunk[--take] = '\0'; /* trim trailing break of this bubble */ + } + if (take > 0) { + queueMessage(vc, TXT_TYPE_PLAIN, nullptr, + getRTCClock()->getCurrentTimeUnique(), nullptr, 0, chunk); + } + p += adv; + remaining -= adv; + } + /* No prompt while held (the app's own initial sync drains the queue) or + * while the app has hidden the v-contact this session; the messages stay + * queued either way. */ + if (!vcontactMsgWaitHeld() && !_vcontact_app_hidden) { + pushMsgWaiting(); + } else { + LOG_INF("msgwait: suppressed (hold=%d hidden=%d), %d queued", + (int)_vcontact_hold_msgwait, (int)_vcontact_app_hidden, + _offline_queue_count); + } +} + +void CompanionMesh::vcontactNotify(const char *text) +{ + if (!isVContactEnabled() || !text || !text[0]) return; + LOG_INF("vcontact notify: %s", text); + if (!vcontactClockValid()) { + /* Would show as 1970: hold it for vcontactClockSynced(). Drop-oldest + * (restart reason + battery is the whole expected population). */ + if (_vcontact_pending_count >= (uint8_t)ARRAY_SIZE(_vcontact_pending)) { + memmove(_vcontact_pending[0], _vcontact_pending[1], + sizeof(_vcontact_pending[0]) * (ARRAY_SIZE(_vcontact_pending) - 1)); + _vcontact_pending_count = ARRAY_SIZE(_vcontact_pending) - 1; + } + strncpy(_vcontact_pending[_vcontact_pending_count], text, + sizeof(_vcontact_pending[0]) - 1); + _vcontact_pending[_vcontact_pending_count][sizeof(_vcontact_pending[0]) - 1] = '\0'; + _vcontact_pending_count++; + return; + } + vcontactQueueText(text); +} + +/* How long SEND_CONFIRMED is held after its PACKET_SENT. A timer, not "the + * next inbound frame": the app answers our MSG_WAITING within one connection + * interval, which would flush it almost as early as inline. Must land well + * inside the 3000 ms est_timeout, or the app's resend races it. */ +#define VCONTACT_CONFIRM_DELAY_MS 300 + +void CompanionMesh::vcontactConfirmWorkHandler(struct k_work *work) +{ + struct k_work_delayable *dwork = k_work_delayable_from_work(work); + ConfirmWork *cw = CONTAINER_OF(dwork, ConfirmWork, work); + cw->self->vcontactFlushConfirm(); +} + +void CompanionMesh::vcontactFlushConfirm() +{ + /* No lock: a race can at worst emit the (idempotent) push twice. */ + uint32_t ack = _vcontact_confirm_ack; + if (ack == 0) { + return; + } + _vcontact_confirm_ack = 0; + k_work_cancel_delayable(&_vcontact_confirm_work.work); + + uint8_t ack_push[8]; + memcpy(ack_push, &ack, 4); + memset(&ack_push[4], 0, 4); /* trip time: 0 ms — never left the box */ + LOG_DBG("vcontact: emitting deferred ack 0x%08x", ack); + sendPush(PUSH_CODE_SEND_CONFIRMED, ack_push, 8); +} + +void CompanionMesh::deriveVContactKey() +{ + /* The Ed25519 public point of a keypair seeded from + * SHA256("zc-vcontact" || self prv_key || counter). + * - A real point: strict clients decompress peer keys and reject the ~50% + * of random 32-byte strings that are not valid points. + * - Seeded from the private key, so nobody else can link the v-contact to + * this node or derive its private half, which is dropped here unused. + * - The counter skips pub_key[0] of 0x00/0xFF, which MeshCore reserves + * (P = 2/256 per try, so still deterministic). */ + static const char vc_salt[] = "zc-vcontact"; + uint8_t material[PRV_KEY_SIZE + 1]; + uint8_t seed[SEED_SIZE]; + mesh::LocalIdentity vc; + + /* writeTo() writes prv || pub; max_len PRV_KEY_SIZE asks for prv alone. */ + if (self_id.writeTo(material, PRV_KEY_SIZE) != PRV_KEY_SIZE) { + /* Cannot happen; fails loudly instead of seeding off stack garbage. */ + LOG_ERR("vcontact: private key unavailable, key not derived"); + mesh::Utils::secureZeroize(material, sizeof(material)); + return; + } + + for (int counter = 0; counter < 256; counter++) { + material[PRV_KEY_SIZE] = (uint8_t)counter; + mesh::Utils::sha256(seed, SEED_SIZE, + (const uint8_t *)vc_salt, sizeof(vc_salt) - 1, + material, sizeof(material)); + vc.fromSeed(seed); + if (vc.pub_key[0] != 0x00 && vc.pub_key[0] != 0xFF) break; + } + memcpy(_vcontact_pubkey, vc.pub_key, PUB_KEY_SIZE); + + /* Wipe the identity key and the discarded private half. LocalIdentity has + * no wipe and no virtuals, so it is cleared wholesale. */ + mesh::Utils::secureZeroize(material, sizeof(material)); + mesh::Utils::secureZeroize(seed, sizeof(seed)); + mesh::Utils::secureZeroize(&vc, sizeof(vc)); +} + +void CompanionMesh::vcontactPushAdvert() +{ + if (!isVContactEnabled()) return; + /* Hidden by the app this session; it returns at the next CMD_APP_START. */ + if (_vcontact_app_hidden) return; + if (!vcontactClockValid()) { + /* Deferred to vcontactClockSynced(); now it would say 1970. */ + return; + } + /* Bump lastmod so incremental syncs pick up the rename or re-enable. */ + _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); + ContactInfo vc; + buildVContact(vc); + uint8_t rsp[CONTACT_FRAME_SIZE]; + size_t n = serializeContact(rsp, vc); /* no header — push code is separate */ + sendPush(PUSH_CODE_NEW_ADVERT, rsp, n); +} + +void CompanionMesh::vcontactPushDeleted() +{ + sendPush(PUSH_CODE_CONTACT_DELETED, _vcontact_pubkey, PUB_KEY_SIZE); +} + +bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) +{ + if (!isVContactEnabled()) return false; + + switch (data[0]) { + case CMD_SEND_TXT_MSG: + /* cmd(1) + txt_type(1) + attempt(1) + timestamp(4) + pub_key_prefix(6) + text(N) */ + if (len >= 14 && isVContactKey(&data[7], 6)) { + uint8_t txt_type = data[1]; + if (txt_type != TXT_TYPE_PLAIN && txt_type != TXT_TYPE_CLI_DATA && + txt_type != TXT_TYPE_CLI_COMMAND) { + writeErrFrame(ERR_UNSUPPORTED); + return true; + } + uint32_t msg_timestamp = get_le32(&data[3]); + + char line[MAX_TEXT_LEN + 1]; + size_t text_len = len - 13; + if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; + memcpy(line, &data[13], text_len); + line[text_len] = '\0'; + + /* The delivery ack the app derives for this (timestamp, attempt, + * text), as composeMsgPacket() computes it; a random one made the + * app resend. */ + uint8_t hbuf[5 + MAX_TEXT_LEN]; + memcpy(hbuf, &data[3], 4); /* timestamp, on-wire LE bytes */ + hbuf[4] = (data[2] & 3); /* attempt & 3 */ + memcpy(&hbuf[5], line, text_len); + uint32_t ack = 0; + mesh::Utils::sha256((uint8_t *)&ack, 4, hbuf, 5 + text_len, + self_id.pub_key, PUB_KEY_SIZE); + if (ack == 0) ack = 1; + + sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 3000); + /* One slot: flush a still-pending confirm first (it is past the + * window that needed deferring) rather than overwrite it. */ + vcontactFlushConfirm(); + _vcontact_confirm_ack = ack; + k_work_reschedule(&_vcontact_confirm_work.work, + K_MSEC(VCONTACT_CONFIRM_DELAY_MS)); + + /* A resend reuses the timestamp: re-ack it (done above) without + * re-running the CLI. */ + bool dup = false; + if (msg_timestamp != 0) { + for (uint8_t i = 0; i < VCONTACT_DEDUP_SLOTS; i++) { + if (_vcontact_recent_ts[i] == msg_timestamp) { dup = true; break; } + } + if (!dup) { + _vcontact_recent_ts[_vcontact_recent_head] = msg_timestamp; + _vcontact_recent_head = + (_vcontact_recent_head + 1) % VCONTACT_DEDUP_SLOTS; + } + } + + if (!dup) { + /* Undo a phone keyboard's autocapitalisation ("Get cad"), on + * character 0 only: it is always inside the command verb. + * Folding more once lowercased admin passwords. After the ack + * hash, which covers the original text. */ + if (line[0] >= 'A' && line[0] <= 'Z') { + line[0] = (char)(line[0] - 'A' + 'a'); + } + + LOG_INF("vcontact CLI: '%s'", line); + char reply[COMPANION_CLI_PREFIX_ROOM + COMPANION_CLI_REPLY_SIZE]; + handleCommand(line, 0, reply); + if (reply[0] != '\0') { + vcontactQueueText(reply); + } + } else { + LOG_DBG("vcontact CLI: dup ts=%u, re-ack only", msg_timestamp); + } + return true; + } + return false; + + case CMD_GET_CONTACT_BY_KEY: + if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { + ContactInfo vc; + buildVContact(vc); + uint8_t rsp[CONTACT_FRAME_SIZE]; + size_t n = serializeContact(rsp, vc, PACKET_CONTACT); + writeFrame(rsp, n); + return true; + } + return false; + + case CMD_GET_ADVERT_PATH: + /* It has no over-the-air advert, and the app treats ERR_NOT_FOUND here + * as fatal. It is this node, so the path is direct. + * [cmd][reserved][7-byte pubkey prefix] */ + if (len >= 2 + 7 && isVContactKey(&data[2], 7)) { + uint8_t rsp[6]; + size_t i = 0; + rsp[i++] = PACKET_ADVERT_PATH; + put_le32(&rsp[i], _vcontact_lastmod ? _vcontact_lastmod + : (uint32_t)getRTCClock()->getCurrentTime()); i += 4; + rsp[i++] = 0; // path_len = 0 → direct (zero hop) + writeFrame(rsp, i); + return true; + } + return false; + + case CMD_ADD_UPDATE_CONTACT: + /* Never into the contacts table; keep only the flags byte the app owns. + * [cmd][32-byte pubkey][type][flags][...] */ + if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { + if (len >= 1 + PUB_KEY_SIZE + 2) { + uint8_t flags = data[1 + PUB_KEY_SIZE + 1]; + if (flags != prefs.v_contact_flags) { + prefs.v_contact_flags = flags; + _store->savePrefs(prefs); + } + } + writeOKFrame(); + return true; + } + return false; + + case CMD_RESET_PATH: + /* Meaningless for a loopback contact: accept and drop. */ + if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { + writeOKFrame(); + return true; + } + return false; + + case CMD_REMOVE_CONTACT: + /* Hide for this session only (see _vcontact_app_hidden); flags are kept + * so the favourite returns with it. */ + if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { + _vcontact_app_hidden = true; + LOG_INF("vcontact: hidden by app delete (this session only)"); + writeOKFrame(); + return true; + } + return false; + + case CMD_SEND_TELEMETRY_REQ: + /* [cmd][3 reserved][32-byte pubkey]. Its telemetry is our own: answer + * the SENT the app waits on, then push a TELEMETRY_RESPONSE under the + * v-contact key. */ + if (len >= 4 + PUB_KEY_SIZE && isVContactKey(&data[4], PUB_KEY_SIZE)) { + uint32_t tag = 0; + getRNG()->random((uint8_t *)&tag, 4); + if (tag == 0) tag = 1; + sendPacketSent(MSG_SEND_SENT_DIRECT, tag, 3000); + + uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; + int i = 0; + rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; + rsp[i++] = 0; /* reserved */ + memcpy(&rsp[i], _vcontact_pubkey, 6); + i += 6; + i += appendSelfTelemetry(&rsp[i], + TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT); + sendPush(rsp[0], &rsp[1], i - 1); + return true; + } + return false; + + default: + /* Every other pubkey-addressed opcode looks the contact up in the table, + * misses, and fails with ERR_NOT_FOUND before any packet exists. */ + return false; + } +} diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index 171116e..b86107e 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -1,1048 +1,1047 @@ -/* - * SPDX-License-Identifier: MIT - * ZephCore BaseChatMesh implementation - */ - #include "BaseChatMesh.h" #include -#include + +// ZEPHCORE: libc headers that Arduino.h pulls in upstream, and the log module +// backing MESH_DEBUG_PRINTLN (see MeshCore.h). #include #include - +#include #include LOG_MODULE_REGISTER(zephcore_basechat, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #ifndef SERVER_RESPONSE_DELAY -#define SERVER_RESPONSE_DELAY 300 + #define SERVER_RESPONSE_DELAY 300 #endif #ifndef TXT_ACK_DELAY -#define TXT_ACK_DELAY 200 + #define TXT_ACK_DELAY 200 #endif -void BaseChatMesh::sendFloodScoped(const ContactInfo &recipient, mesh::Packet *pkt, uint32_t delay_millis) -{ - sendFlood(pkt, delay_millis); +#define CLI_REPLY_DELAY_MILLIS 600 + +void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { + sendFlood(pkt, delay_millis); +} +void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) { + sendFlood(pkt, delay_millis); } -void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis) -{ - sendFlood(pkt, delay_millis); +mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len; + { + AdvertDataBuilder builder(ADV_TYPE_CHAT, name); + app_data_len = builder.encodeTo(app_data); + } + + return createAdvert(self_id, app_data, app_data_len); } -mesh::Packet *BaseChatMesh::createSelfAdvert(const char *name) -{ - uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len; - { - AdvertDataBuilder builder(ADV_TYPE_CHAT, name); - app_data_len = builder.encodeTo(app_data); - } - return createAdvert(self_id, app_data, app_data_len); +mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, double lon) { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len; + { + AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); + app_data_len = builder.encodeTo(app_data); + } + + return createAdvert(self_id, app_data, app_data_len); } -mesh::Packet *BaseChatMesh::createSelfAdvert(const char *name, double lat, double lon) -{ - uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len; - { - AdvertDataBuilder builder(ADV_TYPE_CHAT, name, lat, lon); - app_data_len = builder.encodeTo(app_data); - } - return createAdvert(self_id, app_data, app_data_len); +void BaseChatMesh::sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len) { + if (dest.out_path_len == OUT_PATH_UNKNOWN) { + mesh::Packet* ack = createAck(ack_hash, ack_len); + if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY); + } else { + uint32_t d = TXT_ACK_DELAY; + if (getExtraAckTransmitCount() > 0) { + mesh::Packet* a1 = createMultiAck(ack_hash, ack_len, 1); + if (a1) sendDirect(a1, dest.out_path, dest.out_path_len, d); + d += 300; + } + + mesh::Packet* a2 = createAck(ack_hash, ack_len); + if (a2) sendDirect(a2, dest.out_path, dest.out_path_len, d); + } } -void BaseChatMesh::sendAckTo(const ContactInfo &dest, const uint8_t *ack_hash, uint8_t ack_len) -{ - if (dest.out_path_len == OUT_PATH_UNKNOWN) { - mesh::Packet *ack = createAck(ack_hash, ack_len); - if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY); - } else { - uint32_t d = TXT_ACK_DELAY; - if (getExtraAckTransmitCount() > 0) { - mesh::Packet *a1 = createMultiAck(ack_hash, ack_len, 1); - if (a1) sendDirect(a1, dest.out_path, dest.out_path_len, d); - d += 300; - } - mesh::Packet *a2 = createAck(ack_hash, ack_len); - if (a2) sendDirect(a2, dest.out_path, dest.out_path_len, d); - } +void BaseChatMesh::bootstrapRTCfromContacts() { + uint32_t latest = 0; + for (int i = 0; i < num_contacts; i++) { + if (contacts[i].lastmod > latest) { + latest = contacts[i].lastmod; + } + } + if (latest != 0) { + getRTCClock()->setCurrentTime(latest + 1); + } } -void BaseChatMesh::bootstrapRTCfromContacts() -{ - uint32_t latest = 0; - for (int i = 0; i < num_contacts; i++) { - if (contacts[i].lastmod > latest) { - latest = contacts[i].lastmod; - } - } - if (latest != 0) { - getRTCClock()->setCurrentTime(latest + 1); - } +ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { + // ZEPHCORE: anon (ADV_TYPE_NONE) slots are interspersed with real contacts + // instead of reserved at the front (upstream 07648e33's restructure was not + // adopted, see devdocs/UPSTREAM_TRACKER.md); eviction never takes a + // favourite or an anon slot. + int max_slots = transient_only ? (MAX_CONTACTS + MAX_ANON_CONTACTS) : MAX_CONTACTS; + if (num_contacts < max_slots) { + return &contacts[num_contacts++]; + } else if (transient_only || shouldOverwriteWhenFull()) { + int oldest_idx = -1; + uint32_t oldest_lastmod = 0xFFFFFFFF; + for (int i = 0; i < num_contacts; i++) { + if (transient_only) { + // transient/anon requests only ever recycle an existing anon slot + if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) { + oldest_lastmod = contacts[i].lastmod; + oldest_idx = i; + } + } else { + bool is_favourite = (contacts[i].flags & 0x01) != 0; + if (!is_favourite && contacts[i].lastmod < oldest_lastmod && contacts[i].type != ADV_TYPE_NONE) { + oldest_lastmod = contacts[i].lastmod; + oldest_idx = i; + } + } + } + if (oldest_idx >= 0) { + if (!transient_only) { + // NOTE: do NOT call onContactOverwrite() for a recycled anon slot + onContactOverwrite(contacts[oldest_idx].id.pub_key); + } + return &contacts[oldest_idx]; + } + } + return NULL; // no space, no overwrite or all contacts are all favourites } -ContactInfo *BaseChatMesh::allocateContactSlot(bool transient_only) -{ - int max_slots = transient_only ? (MAX_CONTACTS + MAX_ANON_CONTACTS) : MAX_CONTACTS; - if (num_contacts < max_slots) { - return &contacts[num_contacts++]; - } else if (transient_only || shouldOverwriteWhenFull()) { - int oldest_idx = -1; - uint32_t oldest_lastmod = 0xFFFFFFFF; - for (int i = 0; i < num_contacts; i++) { - if (transient_only) { - // transient/anon requests only ever recycle an existing anon slot - if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) { - oldest_lastmod = contacts[i].lastmod; - oldest_idx = i; - } - } else { - // never evict favourites or transient/anon contacts - bool is_favourite = (contacts[i].flags & 0x01) != 0; - if (!is_favourite && contacts[i].lastmod < oldest_lastmod && contacts[i].type != ADV_TYPE_NONE) { - oldest_lastmod = contacts[i].lastmod; - oldest_idx = i; - } - } - } - if (oldest_idx >= 0) { - if (!transient_only) { - // recycling an anon slot isn't a "contact deleted" event -- the - // app was never told this transient pubkey existed - onContactOverwrite(contacts[oldest_idx].id.pub_key); - } - return &contacts[oldest_idx]; - } - } - return nullptr; +void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) { + ci = ContactInfo{}; // ZEPHCORE: memset trips -Wclass-memaccess (Identity has a ctor) + ci.id = id; + ci.out_path_len = OUT_PATH_UNKNOWN; + StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name)); + ci.type = parser.getType(); + if (parser.hasLatLon()) { + ci.gps_lat = parser.getIntLat(); + ci.gps_lon = parser.getIntLon(); + } + ci.last_advert_timestamp = timestamp; + ci.lastmod = getRTCClock()->getCurrentTime(); } -void BaseChatMesh::populateContactFromAdvert(ContactInfo &ci, const mesh::Identity &id, - const AdvertDataParser &parser, uint32_t timestamp) -{ - ci = ContactInfo{}; - ci.id = id; - ci.out_path_len = OUT_PATH_UNKNOWN; - StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name)); - ci.type = parser.getType(); - if (parser.hasLatLon()) { - ci.gps_lat = parser.getIntLat(); - ci.gps_lon = parser.getIntLon(); - } - ci.last_advert_timestamp = timestamp; - ci.lastmod = getRTCClock()->getCurrentTime(); +void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { + // ZEPHCORE: mesh time-sync sample from every verified advert, except share + // rebroadcasts (transport codes {0,0}), which replay stale stored adverts. + if (!(packet->hasTransportCodes() && + packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0)) { + onAdvertTimeSample(id, timestamp, packet->getPathHashCount()); + } + + AdvertDataParser parser(app_data, app_data_len); + if (!(parser.isValid() && parser.hasName())) { + MESH_DEBUG_PRINTLN("onAdvertRecv: invalid app_data, or name is missing: len=%d", app_data_len); + return; + } + + ContactInfo* from = NULL; + for (int i = 0; i < num_contacts; i++) { + if (id.matches(contacts[i].id)) { // is from one of our contacts + from = &contacts[i]; + if (timestamp <= from->last_advert_timestamp) { // check for replay attacks!! + MESH_DEBUG_PRINTLN("onAdvertRecv: Possible replay attack, name: %s", from->name); + return; + } + break; + } + } + + // save a copy of raw advert packet (to support "Share..." function) + int plen; + { + uint8_t save = packet->header; + packet->header &= ~PH_ROUTE_MASK; + packet->header |= ROUTE_TYPE_FLOOD; // make sure transport codes are NOT saved + plen = packet->writeTo(temp_buf); + packet->header = save; + } + + if (from && from->type == ADV_TYPE_NONE) { // already in contacts, but from a temporary ANON_REQ ? + *from = ContactInfo{}; // clear the anon/temp slot // ZEPHCORE: -Wclass-memaccess + from = NULL; // do normal 'add' flow + } + + bool is_new = false; // true = not in contacts[], false = exists in contacts[] + if (from == NULL) { + if (!shouldAutoAddContactType(parser.getType())) { + ContactInfo ci; + populateContactFromAdvert(ci, id, parser, timestamp); + onDiscoveredContact(ci, true, packet->path_len, packet->path); // let UI know + return; + } + + // check hop limit for new contacts (0 = no limit, 1 = direct (0 hops), N = up to N-1 hops) + uint8_t max_hops = getAutoAddMaxHops(); + if (max_hops > 0 && packet->getPathHashCount() >= max_hops) { + ContactInfo ci; + populateContactFromAdvert(ci, id, parser, timestamp); + onDiscoveredContact(ci, true, packet->path_len, packet->path); // let UI know + return; + } + + from = allocateContactSlot(); + if (from == NULL) { + ContactInfo ci; + populateContactFromAdvert(ci, id, parser, timestamp); + onDiscoveredContact(ci, true, packet->path_len, packet->path); + onContactsFull(); + MESH_DEBUG_PRINTLN("onAdvertRecv: unable to allocate contact slot for new contact"); + return; + } + + populateContactFromAdvert(*from, id, parser, timestamp); + from->sync_since = 0; + from->shared_secret_valid = false; + } + + // update + putBlobByKey(id.pub_key, PUB_KEY_SIZE, temp_buf, plen); + StrHelper::strncpy(from->name, parser.getName(), sizeof(from->name)); + from->type = parser.getType(); + if (parser.hasLatLon()) { + from->gps_lat = parser.getIntLat(); + from->gps_lon = parser.getIntLon(); + } + from->last_advert_timestamp = timestamp; + from->lastmod = getRTCClock()->getCurrentTime(); + + onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know } -void BaseChatMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, - const uint8_t *app_data, size_t app_data_len) -{ - LOG_DBG("onAdvertRecv: timestamp=%u app_data_len=%u", timestamp, (unsigned)app_data_len); - - /* Time-sample hook skips share rebroadcasts (transport codes {0,0}) — - * they replay stale stored adverts and would churn the sender's tenure. */ - if (!(packet->hasTransportCodes() && - packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0)) { - onAdvertTimeSample(id, timestamp, packet->getPathHashCount()); - } - - AdvertDataParser parser(app_data, app_data_len); - if (!(parser.isValid() && parser.hasName())) { - LOG_WRN("onAdvertRecv: invalid parser or no name (valid=%d, hasName=%d)", - parser.isValid(), parser.hasName()); - return; - } - - LOG_DBG("onAdvertRecv: valid advert from '%s' type=%d", parser.getName(), parser.getType()); - - ContactInfo *from = nullptr; - for (int i = 0; i < num_contacts; i++) { - if (id.matches(contacts[i].id)) { - from = &contacts[i]; - if (timestamp <= from->last_advert_timestamp) { - return; // replay attack protection - } - break; - } - } - - // Save raw advert packet for share function - int plen; - { - uint8_t save = packet->header; - packet->header &= ~PH_ROUTE_MASK; - packet->header |= ROUTE_TYPE_FLOOD; - plen = packet->writeTo(temp_buf); - packet->header = save; - } - - if (from && from->type == ADV_TYPE_NONE) { // matched a transient anon/ANON_REQ slot -- promote to a real contact - *from = ContactInfo{}; - from = nullptr; - } - - bool is_new = false; - if (from == nullptr) { - LOG_DBG("onAdvertRecv: new contact, checking auto-add for type %d", parser.getType()); - if (!shouldAutoAddContactType(parser.getType())) { - LOG_DBG("onAdvertRecv: auto-add disabled for type %d, reporting only", parser.getType()); - ContactInfo ci; - populateContactFromAdvert(ci, id, parser, timestamp); - onDiscoveredContact(ci, true, packet->path_len, packet->path); - return; - } - - // check hop limit for new contacts (0 = no limit, 1 = direct (0 hops), N = up to N-1 hops) - uint8_t max_hops = getAutoAddMaxHops(); - if (max_hops > 0 && packet->getPathHashCount() >= max_hops) { - ContactInfo ci; - populateContactFromAdvert(ci, id, parser, timestamp); - onDiscoveredContact(ci, true, packet->path_len, packet->path); - return; - } - - from = allocateContactSlot(); - if (from == nullptr) { - LOG_WRN("onAdvertRecv: contact table full, cannot allocate slot"); - ContactInfo ci; - populateContactFromAdvert(ci, id, parser, timestamp); - onDiscoveredContact(ci, true, packet->path_len, packet->path); - onContactsFull(); - return; - } - - LOG_INF("onAdvertRecv: allocated slot, num_contacts=%d", num_contacts); - populateContactFromAdvert(*from, id, parser, timestamp); - from->sync_since = 0; - from->shared_secret_valid = false; - } else { - LOG_DBG("onAdvertRecv: existing contact, updating"); - } - - // Update contact - putBlobByKey(id.pub_key, PUB_KEY_SIZE, temp_buf, plen); - StrHelper::strncpy(from->name, parser.getName(), sizeof(from->name)); - from->type = parser.getType(); - if (parser.hasLatLon()) { - from->gps_lat = parser.getIntLat(); - from->gps_lon = parser.getIntLon(); - } - from->last_advert_timestamp = timestamp; - from->lastmod = getRTCClock()->getCurrentTime(); - - LOG_DBG("onAdvertRecv: calling onDiscoveredContact is_new=%d", is_new); - onDiscoveredContact(*from, is_new, packet->path_len, packet->path); +int BaseChatMesh::searchPeersByHash(const uint8_t* hash) { + int n = 0; + for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { + if (contacts[i].id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; } -int BaseChatMesh::searchPeersByHash(const uint8_t *hash) -{ - int n = 0; - for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { - if (contacts[i].id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; - } - } - return n; +void BaseChatMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < num_contacts) { + memcpy(dest_secret, contacts[i].getSharedSecret(self_id), PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } } -void BaseChatMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) -{ - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < num_contacts) { - memcpy(dest_secret, contacts[i].getSharedSecret(self_id), PUB_KEY_SIZE); - } +void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= num_contacts) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: Invalid sender idx: %d", i); + return; + } + + ContactInfo& from = contacts[i]; + + if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) + uint8_t flags = data[4] >> 2; // message attempt number, and other flags + + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator + + if (flags == TXT_TYPE_PLAIN) { + from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time + onMessageRecv(from, packet, sender_timestamp, (const char *) &data[5]); // let UI know + + int text_len = strlen((char *)&data[5]); + uint8_t ack_hash[6]; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to sender that we got it + mesh::Utils::sha256(ack_hash, 4, data, 5 + text_len, from.id.pub_key, PUB_KEY_SIZE); + // NEW: append (potential) extended attempt byte (to make packethash unique) + ack_hash[4] = data[5 + text_len + 1]; + getRNG()->random(&ack_hash[5], 1); // make 6th byte random + + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 6); + if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); + } else { + sendAckTo(from, ack_hash, 6); + } + } else if (flags == TXT_TYPE_CLI_DATA) { + char *text = (char *)&data[5]; + onCommandDataRecv(from, packet, sender_timestamp, text); // let UI know + // NOTE: no ack expected for CLI_DATA replies + } else if (flags == TXT_TYPE_CLI_COMMAND) { + uint8_t temp[166]; + char *command = (char *)&data[5]; + char *reply = (char *)&temp[5]; + *reply = 0; + + onCLICommandRecv(from, packet, sender_timestamp, command, reply); // let UI know + // NOTE: no ack expected for CLI_COMMAND replies + + int text_len = strlen(reply); + if (text_len > 0) { + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == sender_timestamp) { + // WORKAROUND: the two timestamps need to be different, in the CLI view + timestamp++; + } + memcpy(temp, ×tamp, 4); + temp[4] = (TXT_TYPE_CLI_DATA << 2); + + auto reply_pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, from.id, secret, temp, 5 + text_len); + if (reply_pkt) { + if (from.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(from, reply_pkt, CLI_REPLY_DELAY_MILLIS); + } else { + sendDirect(reply_pkt, from.out_path, from.out_path_len, CLI_REPLY_DELAY_MILLIS); + } + } + } + } else if (flags == TXT_TYPE_SIGNED_PLAIN) { + if (sender_timestamp > from.sync_since) { // make sure 'sync_since' is up-to-date + from.sync_since = sender_timestamp; + } + from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time + onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9]); // let UI know + + uint32_t ack_hash; // calc truncated hash of the message timestamp + text + OUR pub_key, to prove to sender that we got it + mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 9 + strlen((char *)&data[9]), self_id.pub_key, PUB_KEY_SIZE); + + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the ACK + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_ACK, (uint8_t *) &ack_hash, 4); + if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); + } else { + sendAckTo(from, (uint8_t *) &ack_hash); + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported message type: %u", (uint32_t) flags); + } + } else if (type == PAYLOAD_TYPE_REQ && len > 4) { + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); + uint8_t reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); + if (reply_len > 0) { + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len); + if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); + } else { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len); + if (reply) { + if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT + sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFloodScoped(from, reply, SERVER_RESPONSE_DELAY); + } + } + } + } + } else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) { + onContactResponse(from, data, len); + if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) { + // we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?) + handleReturnPathRetry(from, packet->path, packet->path_len); + } + } } -void BaseChatMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, - const uint8_t *secret, uint8_t *data, size_t len) -{ - LOG_DBG("onPeerDataRecv: type=%d sender_idx=%d len=%u", type, sender_idx, (unsigned)len); - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= num_contacts) { - LOG_WRN("onPeerDataRecv: invalid peer index %d (num_contacts=%d)", i, num_contacts); - return; - } +bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= num_contacts) { + MESH_DEBUG_PRINTLN("onPeerPathRecv: Invalid sender idx: %d", i); + return false; + } - ContactInfo &from = contacts[i]; - LOG_DBG("onPeerDataRecv: from '%s'", from.name); + ContactInfo& from = contacts[i]; - if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { - uint32_t timestamp; - memcpy(×tamp, data, 4); - uint8_t flags = data[4] >> 2; - LOG_DBG("onPeerDataRecv TXT_MSG: timestamp=%u flags=%d (data[4]=0x%02x) text='%s'", - timestamp, flags, data[4], (const char*)&data[5]); - - data[len] = 0; // null terminate - - if (flags == TXT_TYPE_PLAIN) { - LOG_DBG("onPeerDataRecv: flags match TXT_TYPE_PLAIN, calling onMessageRecv"); - from.lastmod = getRTCClock()->getCurrentTime(); - onMessageRecv(from, packet, timestamp, (const char *)&data[5]); - - int text_len = strlen((char *)&data[5]); - uint8_t ack_hash[6]; - mesh::Utils::sha256(ack_hash, 4, data, 5 + text_len, from.id.pub_key, PUB_KEY_SIZE); - ack_hash[4] = data[5 + text_len + 1]; // attempt byte (makes hash unique across retries) - getRNG()->random(&ack_hash[5], 1); // random byte (makes hash unique per delivery node) - - if (packet->isRouteFlood()) { - mesh::Packet *path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, ack_hash, 6); - if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); - } else { - sendAckTo(from, ack_hash, 6); - } - } else if (flags == TXT_TYPE_CLI_DATA) { - onCommandDataRecv(from, packet, timestamp, (const char *)&data[5]); - - if (packet->isRouteFlood()) { - mesh::Packet *path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, nullptr, 0); - if (path) sendFloodScoped(from, path); - } - } else if (flags == TXT_TYPE_SIGNED_PLAIN) { - if (timestamp > from.sync_since) { - from.sync_since = timestamp; - } - from.lastmod = getRTCClock()->getCurrentTime(); - onSignedMessageRecv(from, packet, timestamp, &data[5], (const char *)&data[9]); - - uint32_t ack_hash; - mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 9 + strlen((char *)&data[9]), - self_id.pub_key, PUB_KEY_SIZE); - - if (packet->isRouteFlood()) { - mesh::Packet *path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_ACK, (uint8_t *)&ack_hash, 4); - if (path) sendFloodScoped(from, path, TXT_ACK_DELAY); - } else { - sendAckTo(from, (uint8_t *)&ack_hash, 4); - } - } else { - /* Includes TXT_TYPE_CLI_COMMAND (v1.18+). Upstream's companion - * executes it when the contact carries flag 0x10; ZephCore - * deliberately does not — see devdocs/UPSTREAM_TRACKER.md. Logged - * rather than dropped silently so the case is diagnosable. */ - LOG_DBG("onPeerDataRecv: unhandled text type flags=%d from '%s'", - flags, from.name); - } - } else if (type == PAYLOAD_TYPE_REQ && len > 4) { - uint32_t sender_timestamp; - memcpy(&sender_timestamp, data, 4); - uint8_t reply_len = onContactRequest(from, sender_timestamp, &data[4], len - 4, temp_buf); - if (reply_len > 0) { - if (packet->isRouteFlood()) { - mesh::Packet *path = createPathReturn(from.id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, temp_buf, reply_len); - if (path) sendFloodScoped(from, path, SERVER_RESPONSE_DELAY); - } else { - mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len); - if (reply) { - if (from.out_path_len != OUT_PATH_UNKNOWN) { - sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY); - } else { - sendFloodScoped(from, reply, SERVER_RESPONSE_DELAY); - } - } - } - } - } else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) { - LOG_DBG("onPeerDataRecv: RESPONSE received, len=%d, calling onContactResponse", len); - onContactResponse(from, data, len); - if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) { - handleReturnPathRetry(from, packet->path, packet->path_len); - } - } + return onContactPathRecv(from, packet->path, packet->path_len, path, path_len, extra_type, extra, extra_len); } -bool BaseChatMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t *secret, - uint8_t *path, uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) -{ - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= num_contacts) { - return false; - } +bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { + // NOTE: default impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path. + // FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?) + from.out_path_len = mesh::Packet::copyPath(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect() + from.lastmod = getRTCClock()->getCurrentTime(); - ContactInfo &from = contacts[i]; - return onContactPathRecv(from, packet->path, packet->path_len, path, path_len, extra_type, extra, extra_len); + onContactPathUpdated(from); + + if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { + // also got an encoded ACK! + if (processAck(extra) != NULL) { + txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer + } + } else if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) { + onContactResponse(from, extra, extra_len); + } + return true; // send reciprocal path if necessary } -bool BaseChatMesh::onContactPathRecv(ContactInfo &from, uint8_t *in_path, uint8_t in_path_len, - uint8_t *out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) -{ - /* out_path is from the inner payload of a validated LoRa packet (caller path). - * Existing contract is that the upstream parser bounds the available bytes; - * pass MAX_PATH_SIZE to preserve behavior while making the bound explicit. */ - from.out_path_len = mesh::Packet::copyPath(from.out_path, out_path, MAX_PATH_SIZE, out_path_len); - from.lastmod = getRTCClock()->getCurrentTime(); +void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { + ContactInfo* from; + if ((from = processAck((uint8_t *)&ack_crc)) != NULL) { + txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer + packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit - onContactPathUpdated(from); - - if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { - if (processAck(extra) != nullptr) { - txt_send_timeout = 0; - } - } else if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) { - onContactResponse(from, extra, extra_len); - } - return true; + if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) { + // we have direct path, but other node is still sending flood, so maybe they didn't receive reciprocal path properly(?) + handleReturnPathRetry(*from, packet->path, packet->path_len); + } + } } -void BaseChatMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) -{ - LOG_DBG("onAckRecv: ack_crc=0x%08x route=%s", ack_crc, - packet->isRouteFlood() ? "flood" : "direct"); - ContactInfo *from; - if ((from = processAck((uint8_t *)&ack_crc)) != nullptr) { - LOG_DBG("onAckRecv: ACK processed successfully for '%s'", from->name); - txt_send_timeout = 0; - packet->markDoNotRetransmit(); - - if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) { - handleReturnPathRetry(*from, packet->path, packet->path_len); - } - } else { - LOG_DBG("onAckRecv: ACK not matched (no pending or wrong crc)"); - } +void BaseChatMesh::handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len) { + // NOTE: simplest impl is just to re-send a reciprocal return path to sender (DIRECTLY) + // override this method in various firmwares, if there's a better strategy + mesh::Packet* rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, NULL, 0); + if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); // 3 second delay } -void BaseChatMesh::handleReturnPathRetry(const ContactInfo &contact, const uint8_t *path, uint8_t path_len) -{ - mesh::Packet *rpath = createPathReturn(contact.id, contact.getSharedSecret(self_id), path, path_len, 0, nullptr, 0); - if (rpath) sendDirect(rpath, contact.out_path, contact.out_path_len, 3000); +#ifdef MAX_GROUP_CHANNELS +// ZEPHCORE: an unconfigured channel slot has an all-zero secret. +static bool isChannelSlotEmpty(const ChannelDetails& ch) { + static const uint8_t zeroes32[32] = { 0 }; + return memcmp(ch.channel.secret, zeroes32, sizeof(ch.channel.secret)) == 0; } -/* An unconfigured channel slot has an all-zero secret. */ -static bool isChannelSlotEmpty(const ChannelDetails &ch) -{ - static const uint8_t zeroes32[32] = { 0 }; - return memcmp(ch.channel.secret, zeroes32, sizeof(ch.channel.secret)) == 0; +int BaseChatMesh::searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel dest[], int max_matches) { + int n = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS && n < max_matches; i++) { + // Skip empty/unconfigured slots. An empty slot has an all-zero secret and + // therefore matches null-key group traffic (a node transmitting with an + // unset PSK): the zero-key MAC validates against the empty slot and the + // foreign message is delivered as if it belonged to that channel. Any node + // with a free channel slot would otherwise act as a null-key sink. + // ZEPHCORE: test the secret, not name[0]: CMD_SET_CHANNEL stores whatever + // name the app sends, so a configured channel can have an empty name and a + // cleared slot a stale one. Every free slot also carries the null-key hash + // after the first save + reboot (setChannel() recomputes it on load). + if (isChannelSlotEmpty(channels[i])) continue; + if (channels[i].channel.hash[0] == hash[0]) { + dest[n++] = channels[i].channel; + } + } + return n; +} +#endif + +void BaseChatMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) { + if (type == PAYLOAD_TYPE_GRP_TXT) { + if (len < 5) { + MESH_DEBUG_PRINTLN("onGroupDataRecv: dropping short group text payload len=%d", (uint32_t)len); + return; + } + + uint8_t txt_type = data[4]; + if ((txt_type >> 2) != 0) { + MESH_DEBUG_PRINTLN("onGroupDataRecv: dropping unsupported group text type=%d", (uint32_t)txt_type); + return; + } + + uint32_t timestamp; + memcpy(×tamp, data, 4); + + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator + + // notify UI of this new message + onChannelMessageRecv(channel, packet, timestamp, (const char *) &data[5]); // let UI know + } else if (type == PAYLOAD_TYPE_GRP_DATA) { + if (len < 3) { + MESH_DEBUG_PRINTLN("onGroupDataRecv: dropping short group data payload len=%d", (uint32_t)len); + return; + } + + uint16_t data_type = ((uint16_t)data[0]) | (((uint16_t)data[1]) << 8); + uint8_t data_len = data[2]; + size_t available_len = len - 3; + + if (data_len > available_len) { + MESH_DEBUG_PRINTLN("onGroupDataRecv: dropping malformed group data type=%d len=%d available=%d", + (uint32_t)data_type, (uint32_t)data_len, (uint32_t)available_len); + return; + } + + onChannelDataRecv(channel, packet, data_type, &data[3], data_len); + } } -int BaseChatMesh::searchChannelsByHash(const uint8_t *hash, mesh::GroupChannel dest[], int max_matches) -{ - int n = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS && n < max_matches; i++) { - /* Skip unconfigured slots. The zero-key MAC validates against an - * all-zero secret, so null-key group traffic (a sender with an unset - * PSK) would be decrypted and delivered as if it belonged to that - * slot — every node with a free slot is a null-key sink. - * - * The test is the secret, not name[0] as upstream uses: CMD_SET_CHANNEL - * copies whatever 32-byte name the app supplies, so a configured - * channel may carry an empty name and a cleared slot may keep a stale - * one. The zero secret is the exact condition that makes a slot - * dangerous. - * - * A memset slot's hash is 0x00, which does not collide with the - * null-key hash — but saveChannels() persists all MAX_GROUP_CHANNELS - * slots and onChannelLoaded() runs setChannel() on each, recomputing - * an empty slot's hash from its zero secret. After the first save - * plus reboot every free slot carries the live null-key hash. - * - * Skipping them also keeps empty slots from consuming dest[] entries - * and crowding out a real channel whose hash byte happens to match. */ - if (isChannelSlotEmpty(channels[i])) continue; - if (channels[i].channel.hash[0] == hash[0]) { - dest[n++] = channels[i].channel; - } - } - return n; +mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack) { + int text_len = strlen(text); + if (text_len > MAX_TEXT_LEN) return NULL; + if (attempt > 3 && text_len > MAX_TEXT_LEN-2) return NULL; + + uint8_t temp[5+MAX_TEXT_LEN+1]; + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = (attempt & 3); + memcpy(&temp[5], text, text_len + 1); + + // calc expected ACK reply + mesh::Utils::sha256((uint8_t *)&expected_ack, 4, temp, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); + + int len = 5 + text_len; + if (attempt > 3) { + temp[len++] = 0; // null terminator + temp[len++] = attempt; // hide attempt number at tail end of payload + } + + return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); } -void BaseChatMesh::onGroupDataRecv(mesh::Packet *packet, uint8_t type, const mesh::GroupChannel &channel, - uint8_t *data, size_t len) -{ - if (type == PAYLOAD_TYPE_GRP_TXT) { - uint8_t txt_type = data[4]; - if (len > 5 && (txt_type >> 2) == 0) { - uint32_t timestamp; - memcpy(×tamp, data, 4); - data[len] = 0; - onChannelMessageRecv(channel, packet, timestamp, (const char *)&data[5]); - } - } else if (type == PAYLOAD_TYPE_GRP_DATA) { - if (len < 3) { - LOG_DBG("onGroupDataRecv: dropping short group data payload len=%d", (int)len); - return; - } - uint16_t data_type = ((uint16_t)data[0]) | (((uint16_t)data[1]) << 8); - uint8_t data_len = data[2]; - size_t available_len = len - 3; - if (data_len > available_len) { - LOG_DBG("onGroupDataRecv: dropping malformed group data type=%d len=%d available=%d", - (int)data_type, (int)data_len, (int)available_len); - return; - } - onChannelDataRecv(channel, packet, data_type, &data[3], data_len); - } +int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { + mesh::Packet* pkt = composeMsgPacket(recipient, timestamp, attempt, text, expected_ack); + if (pkt == NULL) return MSG_SEND_FAILED; + + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + + int rc; + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t)); + rc = MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + txt_send_timeout = futureMillis(est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len)); + rc = MSG_SEND_SENT_DIRECT; + } + return rc; } -mesh::Packet *BaseChatMesh::composeMsgPacket(const ContactInfo &recipient, uint32_t timestamp, - uint8_t attempt, const char *text, uint32_t &expected_ack) -{ - int text_len = strlen(text); - if (text_len > MAX_TEXT_LEN) return nullptr; - if (attempt > 3 && text_len > MAX_TEXT_LEN - 2) return nullptr; +int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, uint8_t txt_type, const char* text, uint32_t& est_timeout) { + int text_len = strlen(text); + if (text_len > MAX_TEXT_LEN) return MSG_SEND_FAILED; - uint8_t temp[5 + MAX_TEXT_LEN + 1]; - memcpy(temp, ×tamp, 4); - temp[4] = (attempt & 3); - memcpy(&temp[5], text, text_len + 1); + uint8_t temp[5+MAX_TEXT_LEN+1]; + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = (attempt & 3) | (txt_type << 2); + memcpy(&temp[5], text, text_len + 1); - mesh::Utils::sha256((uint8_t *)&expected_ack, 4, temp, 5 + text_len, self_id.pub_key, PUB_KEY_SIZE); + auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len); + if (pkt == NULL) return MSG_SEND_FAILED; - int len = 5 + text_len; - if (attempt > 3) { - temp[len++] = 0; - temp[len++] = attempt; - } - - return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + int rc; + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t)); + rc = MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + txt_send_timeout = futureMillis(est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len)); + rc = MSG_SEND_SENT_DIRECT; + } + return rc; } -int BaseChatMesh::dispatchToRecipient(mesh::Packet *pkt, const ContactInfo &recipient, - uint32_t &est_timeout, bool set_txt_timeout) -{ - uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); - int rc; - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - sendFloodScoped(recipient, pkt); - est_timeout = calcFloodTimeoutMillisFor(t); - rc = MSG_SEND_SENT_FLOOD; - } else { - sendDirect(pkt, recipient.out_path, recipient.out_path_len); - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - rc = MSG_SEND_SENT_DIRECT; - } - if (set_txt_timeout) txt_send_timeout = futureMillis(est_timeout); - return rc; +bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len, uint32_t* out_hash) { + uint8_t temp[5+MAX_TEXT_LEN+32]; + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = 0; // TXT_TYPE_PLAIN + + snprintf((char *) &temp[5], sizeof(temp) - 5, "%s: ", sender_name); // : // ZEPHCORE: bounded + char *ep = strchr((char *) &temp[5], 0); + int prefix_len = ep - (char *) &temp[5]; + + if (text_len + prefix_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN - prefix_len; + memcpy(ep, text, text_len); + ep[text_len] = 0; // null terminator + + auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len); + if (pkt) { + // ZEPHCORE: hand the caller the packet hash and pre-register it with the + // contention tracker, so heard retransmits are counted (send feedback). + if (out_hash) { + uint32_t h = mesh::ContentionTracker::computePacketHash32(pkt); + *out_hash = h; + getContentionTracker().trackRetransmit(h, (uint32_t)_ms->getMillis()); + } + sendFloodScoped(channel, pkt); + return true; + } + return false; } -int BaseChatMesh::sendMessage(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, - const char *text, uint32_t &expected_ack, uint32_t &est_timeout) -{ - LOG_INF("sendMessage: to '%s' text='%s' attempt=%d path_len=%d", - recipient.name, text, attempt, recipient.out_path_len); +bool BaseChatMesh::sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, uint16_t data_type, const uint8_t* data, int data_len) { + if (data_len < 0) { + MESH_DEBUG_PRINTLN("sendGroupData: invalid negative data_len=%d", data_len); + return false; + } + if (data_len > MAX_GROUP_DATA_LENGTH) { + MESH_DEBUG_PRINTLN("sendGroupData: data_len=%d exceeds max=%d", data_len, MAX_GROUP_DATA_LENGTH); + return false; + } - mesh::Packet *pkt = composeMsgPacket(recipient, timestamp, attempt, text, expected_ack); - if (pkt == nullptr) { - LOG_WRN("sendMessage: composeMsgPacket failed"); - return MSG_SEND_FAILED; - } + uint8_t temp[3 + MAX_GROUP_DATA_LENGTH]; + temp[0] = (uint8_t)(data_type & 0xFF); + temp[1] = (uint8_t)(data_type >> 8); + temp[2] = (uint8_t)data_len; + if (data_len > 0) memcpy(&temp[3], data, data_len); - LOG_DBG("sendMessage: packet created, expected_ack=0x%08x raw_len=%d", - expected_ack, pkt->getRawLength()); + auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, temp, 3 + data_len); + if (pkt == NULL) { + MESH_DEBUG_PRINTLN("sendGroupData: unable to create group datagram, data_len=%d", data_len); + return false; + } - int rc = dispatchToRecipient(pkt, recipient, est_timeout, true); - LOG_DBG("sendMessage: result=%d est_timeout=%u", rc, est_timeout); - return rc; + if (path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(channel, pkt); + } else { + sendDirect(pkt, path, path_len); + } + + return true; } -int BaseChatMesh::sendCommandData(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, - uint8_t txt_type, const char *text, uint32_t &est_timeout) -{ - int text_len = strlen(text); - if (text_len > MAX_TEXT_LEN) return MSG_SEND_FAILED; +bool BaseChatMesh::shareContactZeroHop(const ContactInfo& contact) { + int plen = getBlobByKey(contact.id.pub_key, PUB_KEY_SIZE, temp_buf); // retrieve last raw advert packet + if (plen == 0) return false; // not found - uint8_t temp[5 + MAX_TEXT_LEN + 1]; - memcpy(temp, ×tamp, 4); - temp[4] = (attempt & 3) | (txt_type << 2); - memcpy(&temp[5], text, text_len + 1); + auto packet = obtainNewPacket(); + if (packet == NULL) return false; // no Packets available - mesh::Packet *pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, - recipient.getSharedSecret(self_id), temp, 5 + text_len); - if (pkt == nullptr) return MSG_SEND_FAILED; - - return dispatchToRecipient(pkt, recipient, est_timeout, true); + packet->readFrom(temp_buf, plen); // restore Packet from 'blob' + uint16_t codes[2]; + codes[0] = codes[1] = 0; // { 0, 0 } means 'send this nowhere' + sendZeroHop(packet, codes); + return true; // success } -bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, - const char *sender_name, const char *text, int text_len, uint32_t *out_hash) -{ - uint8_t temp[5 + MAX_TEXT_LEN + 32]; - memcpy(temp, ×tamp, 4); - temp[4] = 0; // TXT_TYPE_PLAIN - - snprintf((char *)&temp[5], sizeof(temp) - 5, "%s: ", sender_name); - char *ep = strchr((char *)&temp[5], 0); - int prefix_len = ep - (char *)&temp[5]; - - if (text_len + prefix_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN - prefix_len; - memcpy(ep, text, text_len); - ep[text_len] = 0; - - mesh::Packet *pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len); - if (pkt) { - if (out_hash) { - /* Stash the FNV-1a hash for the caller and pre-register it with - * the contention tracker so heard retransmits get counted. The - * hash is content-only (payload_type + first 8 payload bytes), - * unaffected by sendFlood's later path-hash bookkeeping. */ - uint32_t h = mesh::ContentionTracker::computePacketHash32(pkt); - *out_hash = h; - getContentionTracker().trackRetransmit(h, (uint32_t)_ms->getMillis()); - } - sendFloodScoped(channel, pkt); - return true; - } - return false; +uint8_t BaseChatMesh::exportContact(const ContactInfo& contact, uint8_t dest_buf[]) { + return getBlobByKey(contact.id.pub_key, PUB_KEY_SIZE, dest_buf); // retrieve last raw advert packet } -bool BaseChatMesh::sendGroupData(mesh::GroupChannel &channel, uint8_t *path, uint8_t path_len, - uint16_t data_type, const uint8_t *data, int data_len) -{ - if (data_len < 0) { - LOG_DBG("sendGroupData: invalid negative data_len=%d", data_len); - return false; - } - if (data_len > MAX_GROUP_DATA_LENGTH) { - LOG_DBG("sendGroupData: data_len=%d exceeds max=%d", data_len, MAX_GROUP_DATA_LENGTH); - return false; - } - - uint8_t temp[3 + MAX_GROUP_DATA_LENGTH]; - temp[0] = (uint8_t)(data_type & 0xFF); - temp[1] = (uint8_t)(data_type >> 8); - temp[2] = (uint8_t)data_len; - if (data_len > 0) memcpy(&temp[3], data, data_len); - - mesh::Packet *pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, temp, 3 + data_len); - if (pkt == nullptr) { - LOG_DBG("sendGroupData: unable to create group datagram, data_len=%d", data_len); - return false; - } - - if (path_len == OUT_PATH_UNKNOWN) { - sendFloodScoped(channel, pkt); - } else { - sendDirect(pkt, path, path_len); - } - return true; +bool BaseChatMesh::importContact(const uint8_t src_buf[], uint8_t len) { + auto pkt = obtainNewPacket(); + if (pkt) { + if (pkt->readFrom(src_buf, len) && pkt->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + pkt->header |= ROUTE_TYPE_FLOOD; // simulate it being received flood-mode + getTables()->clear(pkt); // remove packet hash from table, so we can receive/process it again + _pendingLoopback = pkt; // loop-back, as if received over radio + return true; // success + } else { + releasePacket(pkt); // undo the obtainNewPacket() + } + } + return false; // error } -bool BaseChatMesh::shareContactZeroHop(const ContactInfo &contact) -{ - int plen = getBlobByKey(contact.id.pub_key, PUB_KEY_SIZE, temp_buf); - if (plen == 0) return false; +int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password, uint32_t& est_timeout) { + mesh::Packet* pkt; + { + int tlen; + uint8_t temp[24]; + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(temp, &now, 4); // mostly an extra blob to help make packet_hash unique + if (recipient.type == ADV_TYPE_ROOM) { + memcpy(&temp[4], &recipient.sync_since, 4); + int len = strlen(password); if (len > 15) len = 15; // max 15 chars currently + memcpy(&temp[8], password, len); + tlen = 8 + len; + } else { + int len = strlen(password); if (len > 15) len = 15; // max 15 chars currently + memcpy(&temp[4], password, len); + tlen = 4 + len; + } - mesh::Packet *packet = obtainNewPacket(); - if (packet == nullptr) return false; - - packet->readFrom(temp_buf, plen); - uint16_t codes[2]; - codes[0] = codes[1] = 0; - sendZeroHop(packet, codes); - return true; + pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, tlen); + } + if (pkt) { + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + est_timeout = calcFloodTimeoutMillisFor(t); + return MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + return MSG_SEND_SENT_DIRECT; + } + } + return MSG_SEND_FAILED; } -uint8_t BaseChatMesh::exportContact(const ContactInfo &contact, uint8_t dest_buf[]) -{ - return getBlobByKey(contact.id.pub_key, PUB_KEY_SIZE, dest_buf); +int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data, uint8_t len, uint32_t& tag, uint32_t& est_timeout) { + mesh::Packet* pkt; + { + uint8_t temp[MAX_PACKET_PAYLOAD]; + tag = getRTCClock()->getCurrentTimeUnique(); + memcpy(temp, &tag, 4); // tag to match later (also extra blob to help make packet_hash unique) + memcpy(&temp[4], data, len); + + pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + len); + } + if (pkt) { + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + est_timeout = calcFloodTimeoutMillisFor(t); + return MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + return MSG_SEND_SENT_DIRECT; + } + } + return MSG_SEND_FAILED; } -bool BaseChatMesh::importContact(const uint8_t src_buf[], uint8_t len) -{ - mesh::Packet *pkt = obtainNewPacket(); - if (pkt) { - if (pkt->readFrom(src_buf, len) && pkt->getPayloadType() == PAYLOAD_TYPE_ADVERT) { - pkt->header |= ROUTE_TYPE_FLOOD; - getTables()->clear(pkt); - _pendingLoopback = pkt; - return true; - } else { - releasePacket(pkt); - } - } - return false; +int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_data, uint8_t data_len, uint32_t& tag, uint32_t& est_timeout) { + if (data_len > MAX_PACKET_PAYLOAD - 16) return MSG_SEND_FAILED; + + mesh::Packet* pkt; + { + uint8_t temp[MAX_PACKET_PAYLOAD]; + tag = getRTCClock()->getCurrentTimeUnique(); + memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique + memcpy(&temp[4], req_data, data_len); + + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); + } + if (pkt) { + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + est_timeout = calcFloodTimeoutMillisFor(t); + return MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + return MSG_SEND_SENT_DIRECT; + } + } + return MSG_SEND_FAILED; } -int BaseChatMesh::sendLogin(const ContactInfo &recipient, const char *password, uint32_t &est_timeout) -{ - mesh::Packet *pkt; - { - int tlen; - uint8_t temp[24]; - uint32_t now = getRTCClock()->getCurrentTimeUnique(); - memcpy(temp, &now, 4); - if (recipient.type == ADV_TYPE_ROOM) { - memcpy(&temp[4], &recipient.sync_since, 4); - int len = strlen(password); if (len > 15) len = 15; - memcpy(&temp[8], password, len); - tlen = 8 + len; - } else { - int len = strlen(password); if (len > 15) len = 15; - memcpy(&temp[4], password, len); - tlen = 4 + len; - } +int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, uint32_t& tag, uint32_t& est_timeout) { + mesh::Packet* pkt; + { + uint8_t temp[13]; + tag = getRTCClock()->getCurrentTimeUnique(); + memcpy(temp, &tag, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = req_type; + memset(&temp[5], 0, 4); // reserved (possibly for 'since' param) + getRNG()->random(&temp[9], 4); // random blob to help make packet-hash unique - pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, - recipient.getSharedSecret(self_id), temp, tlen); - } - if (pkt) { - int result = dispatchToRecipient(pkt, recipient, est_timeout, false); - onLoginSent(recipient); - return result; - } - return MSG_SEND_FAILED; + pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); + } + if (pkt) { + uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(recipient, pkt); + est_timeout = calcFloodTimeoutMillisFor(t); + return MSG_SEND_SENT_FLOOD; + } else { + sendDirect(pkt, recipient.out_path, recipient.out_path_len); + est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + return MSG_SEND_SENT_DIRECT; + } + } + return MSG_SEND_FAILED; } -int BaseChatMesh::sendAnonReq(const ContactInfo &recipient, const uint8_t *data, uint8_t len, - uint32_t &tag, uint32_t &est_timeout) -{ - mesh::Packet *pkt; - { - uint8_t temp[MAX_PACKET_PAYLOAD]; - tag = getRTCClock()->getCurrentTimeUnique(); - memcpy(temp, &tag, 4); - memcpy(&temp[4], data, len); - - pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, - recipient.getSharedSecret(self_id), temp, 4 + len); - } - if (pkt) { - return dispatchToRecipient(pkt, recipient, est_timeout, false); - } - return MSG_SEND_FAILED; +bool BaseChatMesh::startConnection(const ContactInfo& contact, uint16_t keep_alive_secs) { + int use_idx = -1; + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].keep_alive_millis == 0) { // free slot? + use_idx = i; + } else if (connections[i].server_id.matches(contact.id)) { // already in table? + use_idx = i; + break; + } + } + if (use_idx < 0) { + return false; // table is full + } + connections[use_idx].server_id = contact.id; + uint32_t interval = connections[use_idx].keep_alive_millis = ((uint32_t)keep_alive_secs)*1000; + connections[use_idx].next_ping = futureMillis(interval); + connections[use_idx].expected_ack = 0; + connections[use_idx].last_activity = getRTCClock()->getCurrentTime(); + return true; // success } -int BaseChatMesh::sendRequest(const ContactInfo &recipient, const uint8_t *req_data, uint8_t data_len, - uint32_t &tag, uint32_t &est_timeout) -{ - if (data_len > MAX_PACKET_PAYLOAD - 16) return MSG_SEND_FAILED; - - mesh::Packet *pkt; - { - uint8_t temp[MAX_PACKET_PAYLOAD]; - tag = getRTCClock()->getCurrentTimeUnique(); - memcpy(temp, &tag, 4); - memcpy(&temp[4], req_data, data_len); - - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); - } - if (pkt) { - return dispatchToRecipient(pkt, recipient, est_timeout, false); - } - return MSG_SEND_FAILED; +void BaseChatMesh::stopConnection(const uint8_t* pub_key) { + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].server_id.matches(pub_key)) { + connections[i].keep_alive_millis = 0; // mark slot as now free + connections[i].next_ping = 0; + connections[i].expected_ack = 0; + connections[i].last_activity = 0; + break; + } + } } -int BaseChatMesh::sendRequest(const ContactInfo &recipient, uint8_t req_type, uint32_t &tag, uint32_t &est_timeout) -{ - mesh::Packet *pkt; - { - uint8_t temp[13]; - tag = getRTCClock()->getCurrentTimeUnique(); - memcpy(temp, &tag, 4); - temp[4] = req_type; - memset(&temp[5], 0, 4); - getRNG()->random(&temp[9], 4); - - pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); - } - if (pkt) { - return dispatchToRecipient(pkt, recipient, est_timeout, false); - } - return MSG_SEND_FAILED; +bool BaseChatMesh::hasConnectionTo(const uint8_t* pub_key) { + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].keep_alive_millis > 0 && connections[i].server_id.matches(pub_key)) return true; + } + return false; } -void BaseChatMesh::resetPathTo(ContactInfo &recipient) -{ - recipient.out_path_len = OUT_PATH_UNKNOWN; +void BaseChatMesh::markConnectionActive(const ContactInfo& contact) { + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].keep_alive_millis > 0 && connections[i].server_id.matches(contact.id)) { + connections[i].last_activity = getRTCClock()->getCurrentTime(); + + // re-schedule next KEEP_ALIVE, now that we have heard from server + connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); + break; + } + } } -static ContactInfo *table_sort; // pass via global for qsort +ContactInfo* BaseChatMesh::checkConnectionsAck(const uint8_t* data) { + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].keep_alive_millis > 0 && memcmp(&connections[i].expected_ack, data, 4) == 0) { + // yes, got an ack for our keep_alive request! + connections[i].expected_ack = 0; + connections[i].last_activity = getRTCClock()->getCurrentTime(); -static int cmp_adv_timestamp(const void *a, const void *b) -{ - int a_idx = *((int *)a); - int b_idx = *((int *)b); - if (table_sort[b_idx].last_advert_timestamp > table_sort[a_idx].last_advert_timestamp) return 1; - if (table_sort[b_idx].last_advert_timestamp < table_sort[a_idx].last_advert_timestamp) return -1; - return 0; + // re-schedule next KEEP_ALIVE, now that we have heard from server + connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); + + auto id = &connections[i].server_id; + return lookupContactByPubKey(id->pub_key, PUB_KEY_SIZE); // yes, a match + } + } + return NULL; /// no match } -void BaseChatMesh::scanRecentContacts(int last_n, ContactVisitor *visitor) -{ - for (int i = 0; i < num_contacts; i++) { - sort_array[i] = i; - } - table_sort = contacts; - qsort(sort_array, num_contacts, sizeof(sort_array[0]), cmp_adv_timestamp); +void BaseChatMesh::checkConnections() { + // scan connections[] table, send KEEP_ALIVE requests + for (int i = 0; i < MAX_CONNECTIONS; i++) { + if (connections[i].keep_alive_millis == 0) continue; // unused slot - if (last_n == 0) { - last_n = num_contacts; - } else { - if (last_n > num_contacts) last_n = num_contacts; - } - for (int i = 0; i < last_n; i++) { - visitor->onContactVisit(contacts[sort_array[i]]); - } + uint32_t now = getRTCClock()->getCurrentTime(); + uint32_t expire_secs = (connections[i].keep_alive_millis / 1000) * 5 / 2; // 2.5 x keep_alive interval + if (now >= connections[i].last_activity + expire_secs) { + // connection now lost + connections[i].keep_alive_millis = 0; + connections[i].next_ping = 0; + connections[i].expected_ack = 0; + connections[i].last_activity = 0; + continue; + } + + if (millisHasNowPassed(connections[i].next_ping)) { + auto contact = lookupContactByPubKey(connections[i].server_id.pub_key, PUB_KEY_SIZE); + if (contact == NULL) { + MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact not found!"); + continue; + } + if (contact->out_path_len == OUT_PATH_UNKNOWN) { + MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact, no out_path!"); + continue; + } + + // send KEEP_ALIVE request + uint8_t data[9]; + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(data, &now, 4); + data[4] = REQ_TYPE_KEEP_ALIVE; + memcpy(&data[5], &contact->sync_since, 4); + + // calc expected ACK reply + mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, data, 9, self_id.pub_key, PUB_KEY_SIZE); + + auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, contact->getSharedSecret(self_id), data, 9); + if (pkt) { + sendDirect(pkt, contact->out_path, contact->out_path_len); + } + + // schedule next KEEP_ALIVE + connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); + } + } } -ContactInfo *BaseChatMesh::searchContactsByPrefix(const char *name_prefix) -{ - int len = strlen(name_prefix); - for (int i = 0; i < num_contacts; i++) { - ContactInfo *c = &contacts[i]; - if (memcmp(c->name, name_prefix, len) == 0) return c; - } - return nullptr; +void BaseChatMesh::resetPathTo(ContactInfo& recipient) { + recipient.out_path_len = OUT_PATH_UNKNOWN; } -ContactInfo *BaseChatMesh::lookupContactByPubKey(const uint8_t *pub_key, int prefix_len) -{ - for (int i = 0; i < num_contacts; i++) { - ContactInfo *c = &contacts[i]; - if (memcmp(c->id.pub_key, pub_key, prefix_len) == 0) return c; - } - return nullptr; +static ContactInfo* table; // pass via global :-( + +static int cmp_adv_timestamp(const void *a, const void *b) { + int a_idx = *((int *)a); + int b_idx = *((int *)b); + if (table[b_idx].last_advert_timestamp > table[a_idx].last_advert_timestamp) return 1; + if (table[b_idx].last_advert_timestamp < table[a_idx].last_advert_timestamp) return -1; + return 0; } -bool BaseChatMesh::addContact(const ContactInfo &contact) -{ - ContactInfo *dest = allocateContactSlot(contact.type == ADV_TYPE_NONE); - if (dest) { - *dest = contact; - dest->shared_secret_valid = false; - return true; - } - return false; +void BaseChatMesh::scanRecentContacts(int last_n, ContactVisitor* visitor) { + for (int i = 0; i < num_contacts; i++) { // sort the INDEXES into contacts[] + sort_array[i] = i; + } + table = contacts; // pass via global *sigh* :-( + qsort(sort_array, num_contacts, sizeof(sort_array[0]), cmp_adv_timestamp); + + if (last_n == 0) { + last_n = num_contacts; // scan ALL + } else { + if (last_n > num_contacts) last_n = num_contacts; + } + for (int i = 0; i < last_n; i++) { + visitor->onContactVisit(contacts[sort_array[i]]); + } } -bool BaseChatMesh::removeContact(ContactInfo &contact) -{ - int idx = 0; - while (idx < num_contacts && !contacts[idx].id.matches(contact.id)) { - idx++; - } - if (idx >= num_contacts) return false; - - num_contacts--; - while (idx < num_contacts) { - contacts[idx] = contacts[idx + 1]; - idx++; - } - return true; +ContactInfo* BaseChatMesh::searchContactsByPrefix(const char* name_prefix) { + int len = strlen(name_prefix); + for (int i = 0; i < num_contacts; i++) { + auto c = &contacts[i]; + if (memcmp(c->name, name_prefix, len) == 0) return c; + } + return NULL; // not found } -ChannelDetails *BaseChatMesh::addChannel(const char *name, const uint8_t *psk, size_t psk_len) -{ - if (num_channels < MAX_GROUP_CHANNELS) { - ChannelDetails *dest = &channels[num_channels]; - - memset(dest->channel.secret, 0, sizeof(dest->channel.secret)); - if (psk_len == 32 || psk_len == 16) { - memcpy(dest->channel.secret, psk, psk_len); - mesh::Utils::sha256(dest->channel.hash, sizeof(dest->channel.hash), dest->channel.secret, psk_len); - StrHelper::strncpy(dest->name, name, sizeof(dest->name)); - num_channels++; - onChannelAdded(dest); - return dest; - } - } - return nullptr; +ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int prefix_len) { + for (int i = 0; i < num_contacts; i++) { + auto c = &contacts[i]; + if (memcmp(c->id.pub_key, pub_key, prefix_len) == 0) return c; + } + return NULL; // not found } -bool BaseChatMesh::getChannel(int idx, ChannelDetails &dest) -{ - if (idx >= 0 && idx < MAX_GROUP_CHANNELS) { - dest = channels[idx]; - return true; - } - return false; +bool BaseChatMesh::addContact(const ContactInfo& contact) { + ContactInfo* dest = allocateContactSlot(contact.type == ADV_TYPE_NONE); + if (dest) { + *dest = contact; + dest->shared_secret_valid = false; // mark shared_secret as needing calculation + return true; // success + } + return false; } -bool BaseChatMesh::setChannel(int idx, const ChannelDetails &src) -{ - static const uint8_t zeroes16[16] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 }; - static const uint8_t zeroes32[32] = { 0 }; +bool BaseChatMesh::removeContact(ContactInfo& contact) { + int idx = 0; + while (idx < num_contacts && !contacts[idx].id.matches(contact.id)) { + idx++; + } + if (idx >= num_contacts) return false; // not found - if (idx >= 0 && idx < MAX_GROUP_CHANNELS) { - channels[idx] = src; - if (memcmp(&src.channel.secret[16], zeroes16, 16) == 0) { - mesh::Utils::sha256(channels[idx].channel.hash, sizeof(channels[idx].channel.hash), - src.channel.secret, 16); - } else { - mesh::Utils::sha256(channels[idx].channel.hash, sizeof(channels[idx].channel.hash), - src.channel.secret, 32); - } - - /* Keep num_channels aligned to highest non-empty slot + 1. - * Needed so startup logic can detect whether channels were loaded. */ - int highest_used = -1; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - if (channels[i].name[0] != '\0' || - memcmp(channels[i].channel.secret, zeroes32, sizeof(zeroes32)) != 0) { - highest_used = i; - } - } - num_channels = highest_used + 1; - return true; - } - return false; + // remove from contacts array + num_contacts--; + while (idx < num_contacts) { + contacts[idx] = contacts[idx + 1]; + idx++; + } + return true; // Success } -int BaseChatMesh::findChannelIdx(const mesh::GroupChannel &ch) -{ - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - if (memcmp(ch.secret, channels[i].channel.secret, sizeof(ch.secret)) == 0) return i; - } - return -1; +#ifdef MAX_GROUP_CHANNELS + +// ZEPHCORE: takes the raw PSK; there is no base64 library in the tree. +ChannelDetails* BaseChatMesh::addChannel(const char* name, const uint8_t* psk, size_t psk_len) { + if (num_channels < MAX_GROUP_CHANNELS) { + auto dest = &channels[num_channels]; + + memset(dest->channel.secret, 0, sizeof(dest->channel.secret)); + int len = (psk_len == 32 || psk_len == 16) ? (int)psk_len : 0; + memcpy(dest->channel.secret, psk, len); + if (len == 32 || len == 16) { + mesh::Utils::sha256(dest->channel.hash, sizeof(dest->channel.hash), dest->channel.secret, len); + StrHelper::strncpy(dest->name, name, sizeof(dest->name)); + num_channels++; + return dest; + } + } + return NULL; +} +bool BaseChatMesh::getChannel(int idx, ChannelDetails& dest) { + if (idx >= 0 && idx < MAX_GROUP_CHANNELS) { + dest = channels[idx]; + return true; + } + return false; +} +bool BaseChatMesh::setChannel(int idx, const ChannelDetails& src) { + static const uint8_t zeroes[] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 }; // ZEPHCORE: const + + if (idx >= 0 && idx < MAX_GROUP_CHANNELS) { + channels[idx] = src; + if (memcmp(&src.channel.secret[16], zeroes, 16) == 0) { + mesh::Utils::sha256(channels[idx].channel.hash, sizeof(channels[idx].channel.hash), src.channel.secret, 16); // 128-bit key + } else { + mesh::Utils::sha256(channels[idx].channel.hash, sizeof(channels[idx].channel.hash), src.channel.secret, 32); // 256-bit key + } + // ZEPHCORE: keep num_channels at the highest used slot + 1, so startup can + // tell whether channels were loaded. + static const uint8_t zeroes32[32] = { 0 }; + int highest_used = -1; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (channels[i].name[0] != '\0' || + memcmp(channels[i].channel.secret, zeroes32, sizeof(zeroes32)) != 0) { + highest_used = i; + } + } + num_channels = highest_used + 1; + return true; + } + return false; +} +int BaseChatMesh::findChannelIdx(const mesh::GroupChannel& ch) { + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (memcmp(ch.secret, channels[i].channel.secret, sizeof(ch.secret)) == 0) return i; + } + return -1; // not found +} +#else +ChannelDetails* BaseChatMesh::addChannel(const char* name, const char* psk_base64) { + return NULL; // not supported +} +bool BaseChatMesh::getChannel(int idx, ChannelDetails& dest) { + return false; +} +bool BaseChatMesh::setChannel(int idx, const ChannelDetails& src) { + return false; +} +int BaseChatMesh::findChannelIdx(const mesh::GroupChannel& ch) { + return -1; // not found +} +#endif + +bool BaseChatMesh::getContactByIdx(uint32_t idx, ContactInfo& contact) { + if (idx >= (uint32_t)num_contacts) return false; // ZEPHCORE: -Wsign-compare + + contact = contacts[idx]; + return true; } -bool BaseChatMesh::getContactByIdx(uint32_t idx, ContactInfo &contact) -{ - if (idx >= (uint32_t)num_contacts) return false; - contact = contacts[idx]; - return true; +ContactsIterator BaseChatMesh::startContactsIterator() { + return ContactsIterator(0); // ZEPHCORE: anon slots are interspersed, not at the front } -ContactsIterator BaseChatMesh::startContactsIterator() -{ - return ContactsIterator(); +bool ContactsIterator::hasNext(const BaseChatMesh* mesh, ContactInfo& dest) { + if (next_idx >= mesh->getTotalContactSlots()) return false; + + dest = mesh->contacts[next_idx++]; + return true; } -bool ContactsIterator::hasNext(const BaseChatMesh *mesh, ContactInfo &dest) -{ - if (next_idx >= mesh->getNumContacts()) return false; - dest = mesh->contacts[next_idx++]; - return true; -} - -/* Connection management for room servers */ -bool BaseChatMesh::startConnection(const ContactInfo &contact, uint16_t keep_alive_secs) -{ - int use_idx = -1; - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis == 0) { // free slot - use_idx = i; - } else if (connections[i].server_id.matches(contact.id)) { // already in table - use_idx = i; - break; - } - } - if (use_idx < 0) return false; // table is full - - connections[use_idx].server_id = contact.id; - uint32_t interval = connections[use_idx].keep_alive_millis = (uint32_t)keep_alive_secs * 1000; - connections[use_idx].next_ping = futureMillis(interval); - connections[use_idx].expected_ack = 0; - connections[use_idx].last_activity = getRTCClock()->getCurrentTime(); - return true; -} - -void BaseChatMesh::stopConnection(const uint8_t *pub_key) -{ - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].server_id.matches(pub_key)) { - connections[i].keep_alive_millis = 0; // mark slot as free - connections[i].next_ping = 0; - connections[i].expected_ack = 0; - connections[i].last_activity = 0; - break; - } - } -} - -bool BaseChatMesh::hasConnectionTo(const uint8_t *pub_key) -{ - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis > 0 && connections[i].server_id.matches(pub_key)) return true; - } - return false; -} - -void BaseChatMesh::markConnectionActive(const ContactInfo &contact) -{ - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis > 0 && connections[i].server_id.matches(contact.id)) { - connections[i].last_activity = getRTCClock()->getCurrentTime(); - // schedule next keep-alive - connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); - return; - } - } -} - -ContactInfo *BaseChatMesh::checkConnectionsAck(const uint8_t *data) -{ - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis > 0 && memcmp(&connections[i].expected_ack, data, 4) == 0) { - // got an ack for our keep_alive request - connections[i].expected_ack = 0; - connections[i].last_activity = getRTCClock()->getCurrentTime(); - connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); - return lookupContactByPubKey(connections[i].server_id.pub_key, PUB_KEY_SIZE); - } - } - return nullptr; -} - -void BaseChatMesh::checkConnections() -{ - // scan connections[] table, send KEEP_ALIVE requests - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis == 0) continue; // unused slot - - uint32_t now = getRTCClock()->getCurrentTime(); - uint32_t expire_secs = (connections[i].keep_alive_millis / 1000) * 5 / 2; // 2.5x keep_alive - if (now >= connections[i].last_activity + expire_secs) { - // connection timed out - connections[i].keep_alive_millis = 0; - connections[i].next_ping = 0; - connections[i].expected_ack = 0; - connections[i].last_activity = 0; - continue; - } - - if (millisHasNowPassed(connections[i].next_ping)) { - ContactInfo *contact = lookupContactByPubKey(connections[i].server_id.pub_key, PUB_KEY_SIZE); - if (contact == nullptr) { - LOG_WRN("checkConnections: keep_alive contact not found"); - continue; - } - if (contact->out_path_len == OUT_PATH_UNKNOWN) { - LOG_WRN("checkConnections: keep_alive contact has no out_path"); - continue; - } - - // Build keepalive request manually (9 bytes, matching Arduino wire format) - uint8_t data[9]; - uint32_t ts = getRTCClock()->getCurrentTimeUnique(); - memcpy(data, &ts, 4); - data[4] = REQ_TYPE_KEEP_ALIVE; - memcpy(&data[5], &contact->sync_since, 4); - - // calc expected ACK reply (SHA256 of data + self pubkey, first 4 bytes) - mesh::Utils::sha256((uint8_t *)&connections[i].expected_ack, 4, - data, 9, self_id.pub_key, PUB_KEY_SIZE); - - auto pkt = createDatagram(PAYLOAD_TYPE_REQ, contact->id, - contact->getSharedSecret(self_id), data, 9); - if (pkt) { - sendDirect(pkt, contact->out_path, contact->out_path_len); - } - - // schedule next KEEP_ALIVE - connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); - } - } -} - -void BaseChatMesh::loop() -{ - Mesh::loop(); - - if (txt_send_timeout && millisHasNowPassed(txt_send_timeout)) { - onSendTimeout(); - txt_send_timeout = 0; - } - - if (_pendingLoopback) { - onRecvPacket(_pendingLoopback); - releasePacket(_pendingLoopback); - _pendingLoopback = nullptr; - } - - checkConnections(); +void BaseChatMesh::loop() { + Mesh::loop(); + + if (txt_send_timeout && millisHasNowPassed(txt_send_timeout)) { + // failed to get an ACK + onSendTimeout(); + txt_send_timeout = 0; + } + + if (_pendingLoopback) { + onRecvPacket(_pendingLoopback); // loop-back, as if received over radio + releasePacket(_pendingLoopback); // undo the obtainNewPacket() + _pendingLoopback = NULL; + } + + checkConnections(); // ZEPHCORE: upstream calls this from MyMesh::loop() } diff --git a/zephcore/helpers/BaseChatMesh.h b/zephcore/helpers/BaseChatMesh.h index 415b416..03f983b 100644 --- a/zephcore/helpers/BaseChatMesh.h +++ b/zephcore/helpers/BaseChatMesh.h @@ -1,241 +1,209 @@ -/* - * SPDX-License-Identifier: MIT - * ZephCore BaseChatMesh - base class for chat-style mesh clients - */ - #pragma once +//#include // needed for PlatformIO #include #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" + +#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) + #include "ContactInfo.h" -#include "ChannelDetails.h" - -#define MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE) - -#ifdef CONFIG_ZEPHCORE_MAX_CONTACTS -#define MAX_CONTACTS CONFIG_ZEPHCORE_MAX_CONTACTS -#else -#define MAX_CONTACTS 32 -#endif - -/* Headroom for transient "anon" contacts (type ADV_TYPE_NONE) created to service - * non-contact requests (CMD_SEND_ANON_REQ to a pubkey not in the contact list). - * These are never persisted or synced to the app. Mirrors upstream. */ -#define MAX_ANON_CONTACTS 8 - -#ifdef CONFIG_ZEPHCORE_MAX_CHANNELS -#define MAX_GROUP_CHANNELS CONFIG_ZEPHCORE_MAX_CHANNELS -#else -#define MAX_GROUP_CHANNELS 8 -#endif #define MAX_SEARCH_RESULTS 8 -#ifdef CONFIG_ZEPHCORE_MAX_CONNECTIONS -#define MAX_CONNECTIONS CONFIG_ZEPHCORE_MAX_CONNECTIONS -#else -#define MAX_CONNECTIONS 16 -#endif - #define MSG_SEND_FAILED 0 #define MSG_SEND_SENT_FLOOD 1 #define MSG_SEND_SENT_DIRECT 2 -#define REQ_TYPE_GET_STATUS 0x01 -#define REQ_TYPE_KEEP_ALIVE 0x02 -#define REQ_TYPE_GET_TELEMETRY 0x03 -#define REQ_TYPE_GET_TELEMETRY_DATA 0x03 // alias +#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS +#define REQ_TYPE_KEEP_ALIVE 0x02 -#define RESP_SERVER_LOGIN_OK 0 +#define RESP_SERVER_LOGIN_OK 0 // response to ANON_REQ -/* Telemetry permissions */ -#define TELEM_PERM_BASE 0x01 -#define TELEM_PERM_LOCATION 0x02 -#define TELEM_PERM_ENVIRONMENT 0x04 - -/* Connection info for room server keep-alive */ -struct ConnectionInfo { - mesh::Identity server_id; - unsigned long next_ping; - uint32_t last_activity; - uint32_t keep_alive_millis; - uint32_t expected_ack; +class ContactVisitor { +public: + virtual void onContactVisit(const ContactInfo& contact) = 0; }; class BaseChatMesh; -class ContactVisitor { +class ContactsIterator { + int next_idx; public: - virtual void onContactVisit(const ContactInfo &contact) = 0; + ContactsIterator(int start) { next_idx = start; } + bool hasNext(const BaseChatMesh* mesh, ContactInfo& dest); }; -class ContactsIterator { - int next_idx = 0; -public: - bool hasNext(const BaseChatMesh *mesh, ContactInfo &dest); +// ZEPHCORE: table sizes come from Kconfig. +#ifdef CONFIG_ZEPHCORE_MAX_CONTACTS + #define MAX_CONTACTS CONFIG_ZEPHCORE_MAX_CONTACTS +#endif +#ifdef CONFIG_ZEPHCORE_MAX_CHANNELS + #define MAX_GROUP_CHANNELS CONFIG_ZEPHCORE_MAX_CHANNELS +#else + #define MAX_GROUP_CHANNELS 8 +#endif +#ifdef CONFIG_ZEPHCORE_MAX_CONNECTIONS + #define MAX_CONNECTIONS CONFIG_ZEPHCORE_MAX_CONNECTIONS +#endif + +#ifndef MAX_CONTACTS + #define MAX_CONTACTS 32 +#endif + +#define MAX_ANON_CONTACTS 8 + +#ifndef MAX_CONNECTIONS + #define MAX_CONNECTIONS 16 +#endif + +struct ConnectionInfo { + mesh::Identity server_id; + unsigned long next_ping; + uint32_t last_activity; + uint32_t keep_alive_millis; + uint32_t expected_ack; }; +#include "ChannelDetails.h" + /** - * Abstract Mesh class for common 'chat' client functionality. - * Handles contact management, message encryption/decryption, ACK handling. + * \brief abstract Mesh class for common 'chat' client */ class BaseChatMesh : public mesh::Mesh { - friend class ContactsIterator; - ContactInfo contacts[MAX_CONTACTS + MAX_ANON_CONTACTS]; - int num_contacts; - int sort_array[MAX_CONTACTS + MAX_ANON_CONTACTS]; - int matching_peer_indexes[MAX_SEARCH_RESULTS]; - unsigned long txt_send_timeout; + friend class ContactsIterator; - ChannelDetails channels[MAX_GROUP_CHANNELS]; - int num_channels; + ContactInfo contacts[MAX_CONTACTS+MAX_ANON_CONTACTS]; + int num_contacts; + int sort_array[MAX_CONTACTS+MAX_ANON_CONTACTS]; + int matching_peer_indexes[MAX_SEARCH_RESULTS]; + unsigned long txt_send_timeout; +#ifdef MAX_GROUP_CHANNELS + ChannelDetails channels[MAX_GROUP_CHANNELS]; + int num_channels; // only for addChannel() +#endif + mesh::Packet* _pendingLoopback; + uint8_t temp_buf[MAX_TRANS_UNIT]; + ConnectionInfo connections[MAX_CONNECTIONS]; - ConnectionInfo connections[MAX_CONNECTIONS]; - - mesh::Packet *_pendingLoopback; - uint8_t temp_buf[MAX_TRANS_UNIT]; - - mesh::Packet *composeMsgPacket(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, - const char *text, uint32_t &expected_ack); - void sendAckTo(const ContactInfo &dest, const uint8_t *ack_hash, uint8_t ack_len = 4); - - /* Shared flood-vs-direct dispatch tail used by sendMessage/sendCommandData/ - * sendLogin/sendAnonReq/sendRequest. Sets est_timeout and (when - * set_txt_timeout) txt_send_timeout; returns MSG_SEND_SENT_FLOOD/DIRECT. */ - int dispatchToRecipient(mesh::Packet *pkt, const ContactInfo &recipient, - uint32_t &est_timeout, bool set_txt_timeout); + mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); + void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); protected: - BaseChatMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, - mesh::PacketManager &mgr, mesh::MeshTables &tables) - : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) - { - num_contacts = 0; - memset(channels, 0, sizeof(channels)); - num_channels = 0; - txt_send_timeout = 0; - _pendingLoopback = nullptr; - } + BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) + { + resetContacts(); - void bootstrapRTCfromContacts(); - void resetContacts() { num_contacts = 0; } - void populateContactFromAdvert(ContactInfo &ci, const mesh::Identity &id, const AdvertDataParser &parser, uint32_t timestamp); - ContactInfo *allocateContactSlot(bool transient_only = false); + #ifdef MAX_GROUP_CHANNELS + memset(channels, 0, sizeof(channels)); + num_channels = 0; + #endif + txt_send_timeout = 0; + _pendingLoopback = NULL; + // ZEPHCORE: no memset(connections): Identity has a ctor (-Wclass-memaccess), + // and the mesh is a static object, so the table starts zeroed. + } - // UI concepts for subclasses to implement - virtual bool isAutoAddEnabled() const { return true; } - virtual bool shouldAutoAddContactType(uint8_t type) const { return true; } - virtual void onContactsFull() {} - virtual bool shouldOverwriteWhenFull() const { return false; } - virtual uint8_t getAutoAddMaxHops() const { return 0; } // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops - virtual void onContactOverwrite(const uint8_t *pub_key) {} - virtual void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t *path) = 0; - virtual ContactInfo *processAck(const uint8_t *data) = 0; - virtual void onContactPathUpdated(const ContactInfo &contact) = 0; - virtual bool onContactPathRecv(ContactInfo &from, uint8_t *in_path, uint8_t in_path_len, - uint8_t *out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len); - virtual void onMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) = 0; - virtual void onCommandDataRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) = 0; - virtual void onSignedMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, uint32_t sender_timestamp, - const uint8_t *sender_prefix, const char *text) = 0; - virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; - virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; - virtual void onSendTimeout() = 0; - virtual void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, const char *text) = 0; - virtual void onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, - const uint8_t *data, size_t data_len) {} - virtual uint8_t onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, - const uint8_t *data, uint8_t len, uint8_t *reply) = 0; - virtual void onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) = 0; - virtual void handleReturnPathRetry(const ContactInfo &contact, const uint8_t *path, uint8_t path_len); + void bootstrapRTCfromContacts(); - virtual void sendFloodScoped(const ContactInfo &recipient, mesh::Packet *pkt, uint32_t delay_millis = 0); - virtual void sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0); + void resetContacts() { + num_contacts = 0; // ZEPHCORE: anon slots are interspersed, none reserved at the front + } + void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp); + ContactInfo* allocateContactSlot(bool transient_only=false); // helper to find slot for new contact - virtual void onLoginSent(const ContactInfo &contact) {} - virtual void onChannelAdded(ChannelDetails *ch) {} + // 'UI' concepts, for sub-classes to implement + virtual bool isAutoAddEnabled() const { return true; } + virtual bool shouldAutoAddContactType(uint8_t type) const { return true; } + virtual void onContactsFull() {}; + virtual bool shouldOverwriteWhenFull() const { return false; } + virtual uint8_t getAutoAddMaxHops() const { return 0; } // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops + virtual void onContactOverwrite(const uint8_t* pub_key) {}; + virtual void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) = 0; + virtual ContactInfo* processAck(const uint8_t *data) = 0; + virtual void onContactPathUpdated(const ContactInfo& contact) = 0; + virtual bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len); + virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; + virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; + virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0; + virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0; + virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; + virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; + virtual void onSendTimeout() = 0; + virtual void onChannelMessageRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t timestamp, const char *text) = 0; + virtual void onChannelDataRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint16_t data_type, + const uint8_t* data, size_t data_len) {} + virtual uint8_t onContactRequest(const ContactInfo& contact, uint32_t sender_timestamp, const uint8_t* data, uint8_t len, uint8_t* reply) = 0; + virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0; + virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len); - /* Every signature-verified advert, before contact filtering/dedup — - * mesh time-sync harvesting hook. */ - virtual void onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, - uint8_t hops) { (void)id; (void)timestamp; (void)hops; } + virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0); + virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0); - // Storage concepts for subclasses to override - virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } - virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } + // ZEPHCORE: every signature-verified advert, before contact filtering and + // dedup: the mesh time-sync harvesting hook. + virtual void onAdvertTimeSample(const mesh::Identity& id, uint32_t timestamp, uint8_t hops) { } - // Mesh overrides - void onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, - const uint8_t *app_data, size_t app_data_len) override; - int searchPeersByHash(const uint8_t *hash) override; - void getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) override; - void onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, - uint8_t *data, size_t len) override; - bool onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path, - uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) override; - void onAckRecv(mesh::Packet *packet, uint32_t ack_crc) override; - int searchChannelsByHash(const uint8_t *hash, mesh::GroupChannel channels[], int max_matches) override; - void onGroupDataRecv(mesh::Packet *packet, uint8_t type, const mesh::GroupChannel &channel, - uint8_t *data, size_t len) override; + // storage concepts, for sub-classes to override/implement + virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } // not implemented + virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } + + // Mesh overrides + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; + bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; + void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; +#ifdef MAX_GROUP_CHANNELS + int searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) override; +#endif + void onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) override; + + // Connections + bool startConnection(const ContactInfo& contact, uint16_t keep_alive_secs); + void stopConnection(const uint8_t* pub_key); + bool hasConnectionTo(const uint8_t* pub_key); + void markConnectionActive(const ContactInfo& contact); + ContactInfo* checkConnectionsAck(const uint8_t* data); + void checkConnections(); public: - mesh::Packet *createSelfAdvert(const char *name); - mesh::Packet *createSelfAdvert(const char *name, double lat, double lon); + mesh::Packet* createSelfAdvert(const char* name); + mesh::Packet* createSelfAdvert(const char* name, double lat, double lon); + // ZEPHCORE: a self advert with the node's location policy, path-hash mode and + // scope, so UI code holding only a BaseChatMesh* can send one. CompanionMesh + // implements it. + virtual bool sendSelfAdvert(bool flood) { return false; } + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout); + int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, uint8_t txt_type, const char* text, uint32_t& est_timeout); + bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len, + uint32_t* out_hash = NULL); // ZEPHCORE: out_hash, for send feedback + bool sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, uint16_t data_type, const uint8_t* data, int data_len); + int sendLogin(const ContactInfo& recipient, const char* password, uint32_t& est_timeout); + int sendAnonReq(const ContactInfo& recipient, const uint8_t* data, uint8_t len, uint32_t& tag, uint32_t& est_timeout); + int sendRequest(const ContactInfo& recipient, uint8_t req_type, uint32_t& tag, uint32_t& est_timeout); + int sendRequest(const ContactInfo& recipient, const uint8_t* req_data, uint8_t data_len, uint32_t& tag, uint32_t& est_timeout); + bool shareContactZeroHop(const ContactInfo& contact); + uint8_t exportContact(const ContactInfo& contact, uint8_t dest_buf[]); + bool importContact(const uint8_t src_buf[], uint8_t len); + void resetPathTo(ContactInfo& recipient); + void scanRecentContacts(int last_n, ContactVisitor* visitor); + ContactInfo* searchContactsByPrefix(const char* name_prefix); + ContactInfo* lookupContactByPubKey(const uint8_t* pub_key, int prefix_len); + bool removeContact(ContactInfo& contact); + bool addContact(const ContactInfo& contact); + int getTotalContactSlots() const { return num_contacts; } + int getNumContacts() const { return num_contacts; } // ZEPHCORE: no reserved slots; anon slots are counted + bool getContactByIdx(uint32_t idx, ContactInfo& contact); + ContactsIterator startContactsIterator(); + ChannelDetails* addChannel(const char* name, const uint8_t* psk, size_t psk_len); // ZEPHCORE: raw PSK + bool getChannel(int idx, ChannelDetails& dest); + bool setChannel(int idx, const ChannelDetails& src); + int findChannelIdx(const mesh::GroupChannel& ch); + int getNumChannels() const { return num_channels; } // ZEPHCORE - /* Build and send a self advert honoring the node's location policy, - * path-hash mode, and transport scope. Overridden by CompanionMesh - * (the only mesh with interactive advert UI triggers); the base default - * does nothing and returns false. Lets UI screens that only hold a - * BaseChatMesh* trigger adverts via the canonical scoped path instead - * of a bare sendFlood() that drops path_hash_mode and scope. */ - virtual bool sendSelfAdvert(bool flood) { (void)flood; return false; } - int sendMessage(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, const char *text, - uint32_t &expected_ack, uint32_t &est_timeout); - /* @param txt_type TXT_TYPE_CLI_DATA (understood by every MeshCore version) - * or TXT_TYPE_CLI_COMMAND (v1.18+ only). Callers that must reach older - * repeaters have to keep using TXT_TYPE_CLI_DATA. */ - int sendCommandData(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, - uint8_t txt_type, const char *text, uint32_t &est_timeout); - /* @param out_hash if non-null, filled with the FNV-1a packet hash so the - * caller can later query the contention tracker for "how many neighbors - * heard and retransmitted this?" — used by the joystick UI's send-feedback - * mechanism. When provided, the packet is also pre-registered with the - * contention tracker (so heard dupes match). */ - bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len, uint32_t *out_hash = nullptr); - bool sendGroupData(mesh::GroupChannel &channel, uint8_t *path, uint8_t path_len, - uint16_t data_type, const uint8_t *data, int data_len); - int sendLogin(const ContactInfo &recipient, const char *password, uint32_t &est_timeout); - int sendAnonReq(const ContactInfo &recipient, const uint8_t *data, uint8_t len, uint32_t &tag, uint32_t &est_timeout); - int sendRequest(const ContactInfo &recipient, uint8_t req_type, uint32_t &tag, uint32_t &est_timeout); - int sendRequest(const ContactInfo &recipient, const uint8_t *req_data, uint8_t data_len, uint32_t &tag, uint32_t &est_timeout); - bool shareContactZeroHop(const ContactInfo &contact); - uint8_t exportContact(const ContactInfo &contact, uint8_t dest_buf[]); - bool importContact(const uint8_t src_buf[], uint8_t len); - void resetPathTo(ContactInfo &recipient); - void scanRecentContacts(int last_n, ContactVisitor *visitor); - ContactInfo *searchContactsByPrefix(const char *name_prefix); - ContactInfo *lookupContactByPubKey(const uint8_t *pub_key, int prefix_len); - bool removeContact(ContactInfo &contact); - bool addContact(const ContactInfo &contact); - int getNumContacts() const { return num_contacts; } - bool getContactByIdx(uint32_t idx, ContactInfo &contact); - ContactsIterator startContactsIterator(); - ChannelDetails *addChannel(const char *name, const uint8_t *psk, size_t psk_len); - bool getChannel(int idx, ChannelDetails &dest); - bool setChannel(int idx, const ChannelDetails &src); - int findChannelIdx(const mesh::GroupChannel &ch); - int getNumChannels() const { return num_channels; } - - /* Connection management for room servers */ - bool startConnection(const ContactInfo &contact, uint16_t keep_alive_secs); - void stopConnection(const uint8_t *pub_key); - bool hasConnectionTo(const uint8_t *pub_key); - void markConnectionActive(const ContactInfo &contact); - ContactInfo *checkConnectionsAck(const uint8_t *data); - void checkConnections(); - - void loop(); + void loop(); }; diff --git a/zephcore/helpers/ChannelDetails.h b/zephcore/helpers/ChannelDetails.h index d4d84c0..62ef75d 100644 --- a/zephcore/helpers/ChannelDetails.h +++ b/zephcore/helpers/ChannelDetails.h @@ -1,14 +1,9 @@ -/* - * SPDX-License-Identifier: MIT - * ChannelDetails - group channel info - */ - #pragma once +//#include #include -#include struct ChannelDetails { - mesh::GroupChannel channel; - char name[32]; + mesh::GroupChannel channel; + char name[32]; }; diff --git a/zephcore/helpers/ContactInfo.h b/zephcore/helpers/ContactInfo.h index dc0a655..cf93cf2 100644 --- a/zephcore/helpers/ContactInfo.h +++ b/zephcore/helpers/ContactInfo.h @@ -1,41 +1,40 @@ -/* - * SPDX-License-Identifier: MIT - * ContactInfo - contact/peer info for mesh - */ - #pragma once +//#include #include -#include #define OUT_PATH_UNKNOWN 0xFF -/* Marker used in BLE offline-queue frames to indicate the message was sent - * by THIS device (not received). Also reused by the joystick UI's local - * message-preview structs for the same purpose. Not a routing value — - * never appears in over-the-air packet path_len fields. */ +// ZEPHCORE: offline-queue frames mark a message this device sent (the joystick +// UI's message previews reuse it). Never an over-the-air path_len. #define OUT_PATH_SENT 0xFE struct ContactInfo { - mesh::Identity id; - char name[32]; - uint8_t type; - uint8_t flags; - uint8_t out_path_len; - mutable bool shared_secret_valid; - uint8_t out_path[MAX_PATH_SIZE]; - uint32_t last_advert_timestamp; - uint32_t lastmod; - int32_t gps_lat, gps_lon; - uint32_t sync_since; + mesh::Identity id; + char name[32]; + uint8_t type; // on of ADV_TYPE_* + uint8_t flags; + uint8_t out_path_len; + mutable bool shared_secret_valid; // flag to indicate if shared_secret has been calculated + uint8_t out_path[MAX_PATH_SIZE]; + uint32_t last_advert_timestamp; // by THEIR clock + uint32_t lastmod; // by OUR clock + int32_t gps_lat, gps_lon; // 6 dec places + uint32_t sync_since; - const uint8_t *getSharedSecret(const mesh::LocalIdentity &self_id) const { - if (!shared_secret_valid) { - self_id.calcSharedSecret(shared_secret, id.pub_key); - shared_secret_valid = true; - } - return shared_secret; - } + const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { + if (!shared_secret_valid) { + self_id.calcSharedSecret(shared_secret, id.pub_key); + shared_secret_valid = true; + } + return shared_secret; + } + + bool isFav() const { return flags & 0x01; } + bool isTelemBaseAllowed() const { return flags & 0x02; } + bool isTelemLocAllowed() const { return flags & 0x04; } + bool isTelemEnvAllowed() const { return flags & 0x08; } + bool isRemoteCLIAllowed() const { return flags & 0x10; } private: - mutable uint8_t shared_secret[PUB_KEY_SIZE]; + mutable uint8_t shared_secret[PUB_KEY_SIZE]; }; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 245c31b..6f3aa2b 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -1,11 +1,9 @@ /* * SPDX-License-Identifier: MIT - * ZephCore - CompanionMesh (Event-Driven) + * ZephCore companion: composition root and mesh event loop. * - * BLE stack is in adapters/ble/ZephyrBLE.cpp. - * USB CDC transport is in adapters/usb/ZephyrUsbCompanion.cpp. - * UI ↔ mesh actions are in helpers/ui/ui_mesh_actions.cpp. - * This file handles: mesh event loop, LoRa, GPS, data storage, init. + * Transports: adapters/ble/ZephyrBLE.cpp, adapters/usb/ZephyrCompanionUSB.cpp. + * UI to mesh actions: helpers/ui/ui_mesh_actions.cpp. */ #include @@ -72,50 +70,38 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #endif -/* - * BLE Controller assert handler — without this, a controller assert - * silently freezes the CPU at highest IRQ priority with zero log output. - * This prints the file/line and reboots so we can actually see what happened. - */ +/* Without this a BLE controller assert freezes the CPU at top IRQ priority + * with no output; log the location and reboot. */ #if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) { LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line); - /* Force flush logs before reboot */ - k_sleep(K_MSEC(100)); + k_sleep(K_MSEC(100)); /* let the log drain */ sys_reboot(SYS_REBOOT_COLD); } #endif -/* - * Event-driven mesh loop - replaces 50ms polling with true event signaling. - * Events are signaled from ISR/callbacks, mesh loop wakes immediately. - * NO periodic timer - CPU sleeps until actual events occur. - */ +/* The mesh thread blocks on these; ISRs and callbacks post them. */ #define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */ -#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete (event-driven!) */ -#define MESH_EVENT_BLE_RX BIT(2) /* BLE frame received */ +#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */ +#define MESH_EVENT_BLE_RX BIT(2) /* BLE/USB frame received */ #define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */ #define MESH_EVENT_UI_ACTION BIT(4) /* Button action from UI (deferred to mesh thread) */ -#define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (must run on main thread!) */ +#define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (main thread only) */ #define MESH_EVENT_TX_DRAIN BIT(6) /* Outbound packet delay expired, run checkSend */ #define MESH_EVENT_PREFS_DIRTY BIT(8) /* Prefs mutated off-main; main flushes to flash */ #define MESH_EVENT_RTC_SAVE BIT(9) /* Hardware-RTC write requested off-main */ #define MESH_EVENT_CONTACT_ITER BIT(10) /* Continue contact-dump iteration on main thread */ #ifdef ZEPHCORE_LORA -/* Forward decls — data_store + companion_mesh_ptr statics are defined further - * down in the file, so mesh_event_loop() can't reference them directly. */ static void save_prefs_to_flash(void); static void vcontact_battery_alert_check(void); #endif -/* Pending epoch for a deferred zephcore_rtc_save(). gps_fix_callback runs on - * the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions - * (burst read/write, several ms each) would stall NMEA ingest — same hazard - * documented for prefs flash writes below. We stash the latest epoch and let - * the main thread perform the actual I2C write via MESH_EVENT_RTC_SAVE; - * concurrent posts coalesce into one save of the latest time. */ +/* Epoch for a deferred hardware-RTC write. gps_fix_callback runs on the GNSS + * modem_chat thread, where several ms of blocking I2C would stall NMEA ingest, + * so the main thread does the write (MESH_EVENT_RTC_SAVE); posts coalesce to + * the latest time. */ static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); #define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ @@ -129,23 +115,17 @@ static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); #define MESH_EVENT_ALL MESH_EVENT_BASE #endif -/* Housekeeping interval - infrequent to preserve power savings */ #define HOUSEKEEPING_INTERVAL_MS CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS -/* Event-driven mesh loop - k_event for signaling from ISR/callbacks */ static struct k_event mesh_events; -/* Defer a hardware-RTC write to the main thread (see pending_rtc_epoch above - * for why gps_fix_callback can't do this inline). Coalesces like prefs-dirty: - * only the latest epoch survives if multiple fixes land before the main loop - * services the event. */ +/* See pending_rtc_epoch. */ static void request_rtc_save(uint32_t epoch) { atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch); k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE); } -/* Work items for event-driven processing */ static void process_companion_rx(void); /* runs on MAIN thread (see ble_on_rx_frame) */ static void run_contact_iteration(void); /* runs on MAIN thread (see MESH_EVENT_CONTACT_ITER) */ static void housekeeping_timer_fn(struct k_timer *timer); @@ -167,42 +147,35 @@ static void joystick_signal_tx(void) } #endif +/* Threading rule for everything below: the mesh state (packet pool, + * dispatcher, contact table) belongs to the MAIN thread. Transport callbacks + * run on sysworkq or in ISR context, so they only queue and post an event; + * the work happens in the event loop. Breaking this caused the stuck + * "Sending..." bug (a reply parsed on sysworkq raced loop()). */ + #if ZEPHCORE_USB_STACK -/* Completed USB text-CLI lines are run on the MAIN thread (the USB adapter - * assembles them on sysworkq). CommonCLI::handleCommand mutates mesh state - * shared with loop(), so it can't run on sysworkq — same hazard as the - * binary protocol path. Drained on MESH_EVENT_BLE_RX. */ +/* Completed USB text-CLI lines, drained on MESH_EVENT_BLE_RX. */ #define CLI_LINE_BUF_SIZE 256 struct companion_cli_line { char buf[CLI_LINE_BUF_SIZE]; }; K_MSGQ_DEFINE(companion_cli_queue, sizeof(struct companion_cli_line), 4, 4); #endif #if ZEPHCORE_USB_STACK -/* USB TX-drained callback (mirrors BLE on_tx_idle): the TX ring emptied, so - * resume the contact pump to queue the next batch. Runs in the CDC TX - * interrupt-callback context — k_event_post is ISR-safe. The contact dump - * reads the contact table, which the main thread can mutate (addContact on - * inbound advert), so it MUST run on the main thread, not here. */ +/* USB TX ring drained (CDC TX interrupt context): resume the contact dump. */ static void usb_on_tx_drain(void) { k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER); } #endif -/* Housekeeping timer for periodic tasks (noise floor calibration, etc.) - * Fires every 5 seconds to wake event loop for maintenance without - * compromising event-driven power savings. */ K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL); -/* Forward declarations */ #ifdef ZEPHCORE_LORA static CompanionMesh *companion_mesh_ptr; #endif /* ========== BLE callbacks → main ========== */ -/* BLE RX callback — called from BLE adapter when a frame arrives. - * Queues to recv_queue and wakes mesh event loop. */ static void ble_on_rx_frame(const uint8_t *data, uint16_t len) { struct { @@ -222,43 +195,32 @@ static void ble_on_rx_frame(const uint8_t *data, uint16_t len) return; } - /* Wake the MAIN thread to parse the frame. handleProtocolFrame() - * mutates the lock-free packet pool / dispatcher that loop() also - * touches, so it MUST run on the main thread — parsing on sysworkq - * races the main loop (the source of the stuck-"Sending…" bug when an - * inbound reply is processed while a send command is parsed). */ k_event_post(&mesh_events, MESH_EVENT_BLE_RX); } -/* BLE TX idle callback — called when TX queue is empty. - * Continues contact iteration on the MAIN thread (the dump reads the contact - * table, which the main thread can mutate — so it can't run on sysworkq). */ +/* BLE TX queue empty: resume the contact dump and the TX drain. */ static void ble_on_tx_idle(void) { k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER); k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); } -/* BLE connected callback — notify UI, clear USB state if needed */ static void ble_on_connected(void) { ui_notify(UI_EVENT_BLE_CONNECTED); } -/* Per-session cleanup shared by BLE disconnect and USB session-end. - * Silently cancels in-progress contact iteration (a reconnect would otherwise - * send a stale PACKET_CONTACT_END to the new session and confuse the phone's - * sync state machine), resets message sync (un-ACKed peeked message stays in - * the queue and is re-sent on the next CMD_SYNC_NEXT_MESSAGE), and frees the - * Ed25519 sign buffer if a sign op was interrupted (else an 8KB leak). */ -/* One deferred reply is enough: RX pauses until it has been accepted by the - * transport. A msgq also lets the transport's disconnect callback purge it. */ +/* One deferred reply is enough: RX pauses until the transport has accepted + * it. A msgq also lets the disconnect path purge it. */ struct pending_reply_frame { uint16_t len; uint8_t buf[MAX_FRAME_SIZE]; }; K_MSGQ_DEFINE(pending_reply, sizeof(struct pending_reply_frame), 1, 4); +/* Session end (BLE disconnect or USB close): drop the contact dump (a stale + * PACKET_CONTACT_END would confuse the next session), keep the un-ACKed + * message queued for re-send, and free an abandoned sign buffer. */ static void companion_session_cleanup(void) { k_msgq_purge(&pending_reply); @@ -270,16 +232,12 @@ static void companion_session_cleanup(void) } #if ZEPHCORE_USB_STACK -/* USB session start — mirror ble_on_connected's UI notification so the - * device shows "connected" during a USB session (matches Arduino, which - * lights the indicator for serial transports too). */ +/* A USB session shows as "connected" too, as upstream does for serial. */ static void usb_on_session_start(void) { ui_notify(UI_EVENT_BLE_CONNECTED); } -/* USB session end (host closed the port) — same cleanup + UI notification - * as ble_on_disconnected. */ static void usb_on_session_end(void) { companion_session_cleanup(); @@ -287,7 +245,6 @@ static void usb_on_session_end(void) } #endif -/* BLE disconnected callback — clean up state and notify UI */ static void ble_on_disconnected(void) { #if ZEPHCORE_USB_STACK @@ -319,13 +276,10 @@ static const struct ble_callbacks ble_cbs = { static size_t transport_write_frame(const uint8_t *src, size_t len) { #if ZEPHCORE_USB_STACK - /* When USB owns the interface, replies must go out the CDC port — the BLE - * send queue is never drained while USB is active (tx_drain no-ops on - * IFACE_USB), so routing through zephcore_ble_send would strand the frame. */ + /* While USB owns the interface the BLE queue is never drained. */ if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - /* A text-CLI session owns USB — swallow binary V3 output so the serial - * console never sees framed garbage. Report success so callers don't - * retry/back off on a frame we intentionally dropped. */ + /* A text-CLI session gets no binary frames; report success so the + * caller does not retry one we dropped on purpose. */ if (zephcore_usb_companion_is_text_session()) { return len; } @@ -344,9 +298,8 @@ static size_t write_frame(const uint8_t *src, size_t len) if (transport_write_frame(src, len) == len) { return len; } - /* Only protocol replies need retention; pushes retain their existing - * lossy semantics. Report success once we own a copy, so callers that - * already retry (contact/message sync) don't send it twice. */ + /* Only replies are retained (pushes stay lossy). Success once we hold a + * copy, so retrying callers (contact/message sync) do not send it twice. */ if (src[0] < 0x80) { struct pending_reply_frame f; f.len = len; @@ -367,16 +320,8 @@ static void retry_pending_reply(void) } } -/** - * Push notification callback - sends push frames to BLE. - * Push frames have format: [push_code] [data...] - */ -/* True when a binary companion client is actually listening. - * - * A USB companion session is "connected" too, but zephcore_ble_is_connected() - * only reports the BLE link — without the USB check, pushes (new messages, - * etc.) are silently dropped on a USB-attached client. A text-CLI session is - * not a binary companion, so V3 frames must not go to it. */ +/* A binary companion client is listening: BLE, or a USB session that is not + * the text CLI (zephcore_ble_is_connected() alone dropped pushes over USB). */ static bool companion_transport_up(void) { bool up = zephcore_ble_is_connected(); @@ -387,12 +332,11 @@ static bool companion_transport_up(void) return up; } +/* [push_code][data...] */ static void push_callback(uint8_t code, const uint8_t *data, size_t len) { - /* No point serializing if nobody is listening. */ if (!companion_transport_up()) return; - /* Push frame: code byte + optional data */ uint8_t push_buf[1 + MAX_FRAME_SIZE - 1]; size_t total_len = 1 + len; @@ -411,11 +355,7 @@ static void push_callback(uint8_t code, const uint8_t *data, size_t len) write_frame(push_buf, total_len); } -/* RX processing work - handles received BLE/USB frames */ -/* Parse inbound BLE/USB binary frames on the MAIN thread. Called from the - * event loop on MESH_EVENT_BLE_RX (formerly a sysworkq work item — moved to - * the main thread so handleProtocolFrame's mesh-state mutation can't race - * loop()). */ +/* Inbound binary frames, on MESH_EVENT_BLE_RX. */ static void process_companion_rx(void) { struct { @@ -429,11 +369,9 @@ static void process_companion_rx(void) k_msgq_get(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) == 0) { handled = true; #ifdef ZEPHCORE_LORA - /* An in-flight contact dump deliberately survives commands parsed - * here — it is only cancelled by CMD_APP_START (new session) or by - * disconnect. Do not abort it on inbound traffic: the app talks to - * us mid-sync, and truncating the dump left it waiting forever. */ - if (!companion_mesh_ptr->handleProtocolFrame(f.buf, f.len)) { + /* A contact dump survives commands in between (the app talks to us + * mid-sync); only CMD_APP_START or a disconnect ends it. */ + if (!companion_mesh_ptr->handleCmdFrame(f.buf, f.len)) { LOG_DBG("rx_process: unknown cmd 0x%02x len=%u", f.buf[0], (unsigned)f.len); uint8_t err_rsp[] = { 0x01, 0x01 }; /* PACKET_ERROR, ERR_UNSUPPORTED */ write_frame(err_rsp, sizeof(err_rsp)); @@ -443,31 +381,18 @@ static void process_companion_rx(void) ARG_UNUSED(handled); #if ZEPHCORE_USB_STACK - /* Over USB, write_frame is synchronous and never kicks the BLE - * tx-drain/on_tx_idle pump that paces multi-frame responses (e.g. contact - * sync, which only emits PACKET_CONTACT_START in the command handler and - * relies on continueContactIteration() for the rest). Kick the pump here so - * any iteration started by the command(s) just handled actually proceeds; - * the USB branch of tx_drain fires on_tx_idle and the loop self-sustains. - * - * Only after a frame was actually handled. The USB tx_drain fires - * on_tx_idle whenever the (BLE) send queue is empty, which over USB is - * always, and on_tx_idle posts MESH_EVENT_CONTACT_ITER -- one of the - * events that calls this function. An unconditional kick therefore closed - * a loop through the system workqueue that never let the main thread - * block: 100% CPU for as long as USB was the active interface, starving - * every preemptible thread below main, including the LoRa TX-wait thread - * (every transmit then ran into its 5 s watchdog). */ + /* Over USB nothing else kicks the pump that paces multi-frame responses + * (the contact dump), so kick it here. Only after a handled frame: over + * USB the kick always posts MESH_EVENT_CONTACT_ITER, which calls this + * function, and an unconditional kick spun the main thread at 100% CPU + * and starved the LoRa TX-wait thread. */ if (handled && zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { zephcore_ble_kick_tx(); } #endif } -/* Continue the contact-dump iteration when TX has space. Runs on the MAIN - * thread (driven by MESH_EVENT_CONTACT_ITER, posted from the BLE/USB tx-idle - * callbacks) so reading the contact table never races a main-thread - * addContact() from an inbound advert. */ +/* The contact dump, as far as TX has room (MESH_EVENT_CONTACT_ITER). */ static void run_contact_iteration(void) { #ifdef ZEPHCORE_LORA @@ -477,12 +402,8 @@ static void run_contact_iteration(void) #if ZEPHCORE_USB_STACK if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - /* Event-driven pacing (no fixed delay): fill the USB TX ring as far - * as it'll hold, then stop. The CDC TX ISR drains it to the wire at - * full speed and calls usb_on_tx_drain() when empty, which re-kicks - * this work to queue the next batch — real backpressure, like BLE's - * on_tx_idle. tx_has_space() guarantees each continueContactIteration - * frame fits, so writeFrame never fails mid-dump. */ + /* Fill the USB TX ring, then stop; usb_on_tx_drain() brings us back + * when it is empty. tx_has_space() guarantees the next frame fits. */ while (zephcore_usb_companion_tx_has_space(CONTACT_FRAME_SIZE)) { if (!companion_mesh_ptr->continueContactIteration()) { break; /* iteration complete */ @@ -492,8 +413,7 @@ static void run_contact_iteration(void) } #endif - /* Don't iterate while BLE TX is congested — wait for drain to clear. - * Also check 2/3 high-water mark (catches stray frames before full). */ + /* Not while BLE TX is congested, and not above a 2/3 high-water mark. */ if (zephcore_ble_is_congested()) { return; } @@ -504,31 +424,22 @@ static void run_contact_iteration(void) if (has_space && companion_mesh_ptr) { if (companion_mesh_ptr->continueContactIteration()) { - /* More contacts to send - kick TX drain which will re-trigger us */ - zephcore_ble_kick_tx(); + zephcore_ble_kick_tx(); /* more to send; the drain brings us back */ } } #endif } -/* - * Event-driven mesh loop - runs in main thread context. - * Wakes on actual events: LoRa RX, LoRa TX done, BLE RX. - * Plus a 5-second housekeeping timer for noise floor calibration, etc. - */ +/* The main thread: blocks until an event, plus the housekeeping tick. */ static void mesh_event_loop(void) { LOG_INF("starting event-driven loop"); - /* Start housekeeping timer for periodic maintenance tasks */ k_timer_start(&housekeeping_timer, K_MSEC(HOUSEKEEPING_INTERVAL_MS), K_MSEC(HOUSEKEEPING_INTERVAL_MS)); for (;;) { - /* Wait for any mesh event - blocks until signaled */ uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); - - /* Clear the events we're handling */ k_event_clear(&mesh_events, events); /* TX-idle resumes both the retained reply and commands left in RX. @@ -536,24 +447,19 @@ static void mesh_event_loop(void) retry_pending_reply(); #ifdef ZEPHCORE_LORA - /* Handle deferred UI button actions (flood advert, pref saves). - * Must run in this thread — LoRa TX and flash writes block. */ + /* UI button actions (advert, pref saves) */ if (events & MESH_EVENT_UI_ACTION) { mesh_handle_ui_actions(); } - /* Handle deferred GPS state transitions (wake/timeout). - * MUST run here (main thread) because GNSS configuration uses - * modem_chat_run_script() which blocks on a semaphore signaled - * from the system work queue. Running it on the system work - * queue would deadlock. */ + /* GPS wake/timeout. GNSS configuration blocks on a semaphore the + * system work queue signals, so it would deadlock there. */ if (events & MESH_EVENT_GPS_ACTION) { gps_process_event(); } - /* Parse inbound BLE/USB frames + USB text-CLI lines HERE (main - * thread) before loop() drains any outbound they enqueued — keeps - * all mesh-state mutation on one thread (see ble_on_rx_frame). */ + /* Inbound frames and CLI lines, before loop() drains what they + * queued. */ if (events & (MESH_EVENT_BLE_RX | MESH_EVENT_CONTACT_ITER | MESH_EVENT_HOUSEKEEPING)) { process_companion_rx(); @@ -565,34 +471,27 @@ static void mesh_event_loop(void) #endif } - /* Continue contact-dump iteration on the main thread (see - * run_contact_iteration / MESH_EVENT_CONTACT_ITER). */ if (events & MESH_EVENT_CONTACT_ITER) { run_contact_iteration(); } - /* Packet processing — only on radio/BLE/TX events */ + /* Packet processing only on radio/transport/TX events */ if (companion_mesh_ptr && (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN))) { companion_mesh_ptr->loop(); } - /* Periodic housekeeping — maintenance + UI refresh */ if (events & MESH_EVENT_HOUSEKEEPING) { - /* Radio maintenance: noise floor calibration, adaptive-CAD - * probe, RX watchdog. Separated from loop() so these - * never run on packet-driven events. */ + /* Noise floor, adaptive-CAD probe, RX watchdog: never on + * packet-driven events. */ if (companion_mesh_ptr) { companion_mesh_ptr->maintenanceLoop(); } - /* Contact-dump watchdog — the dump is pumped solely by the - * BLE/USB tx-idle callback, so a single lost kick strands it - * silently and the app waits for contacts that never arrive. - * Only re-kick when the cursor has not moved across a whole - * tick: a large dump legitimately spans several ticks, and - * back-pressure resolves itself. */ + /* Contact-dump watchdog: only the tx-idle callback pumps the + * dump, so one lost kick strands it. Re-kick only if the + * cursor did not move for a whole tick. */ static int wd_last_iter_idx = -1; if (companion_mesh_ptr && companion_mesh_ptr->isContactIterActive()) { int idx = companion_mesh_ptr->getContactIterIdx(); @@ -605,28 +504,15 @@ static void mesh_event_loop(void) wd_last_iter_idx = -1; } - /* Offline-queue stall watchdog — the queue is drained - * solely by the app answering a single best-effort - * MSG_WAITING push, so one dropped or suppressed prompt - * strands every queued message until the next connect - * (the "v-contact replies only arrive on reconnect" - * failure). Same shape as the contact-dump watchdog - * above: self-throttled, re-prompts only after the app - * has gone quiet. - * - * Gated on a live client: prompting into a dead link - * accomplishes nothing, spams the log every cycle, and - * burns the re-prompt timer so the first prompt after a - * reconnect would be late. A reconnect drains the queue - * through the initial sync anyway. */ + /* Offline-queue watchdog (see msgWaitingWatchdog), only with + * a live client: a reconnect drains the queue anyway, and a + * prompt into a dead link would only delay the next one. */ if (companion_mesh_ptr && companion_transport_up()) { companion_mesh_ptr->msgWaitingWatchdog(); } - /* BLE advertising watchdog — if bt_le_adv_start failed - * transiently (HCI timeout, controller pacing) the device - * would silently stop advertising and be undiscoverable - * until next reboot. Cheap to nudge it back here. */ + /* Advertising watchdog: a transient bt_le_adv_start failure + * otherwise leaves the node undiscoverable until reboot. */ if (zephcore_ble_is_enabled() && !zephcore_ble_is_connected() && !zephcore_ble_is_advertising()) { LOG_WRN("BLE adv watchdog: not advertising, re-enabling"); zephcore_ble_set_enabled(true); @@ -634,41 +520,32 @@ static void mesh_event_loop(void) mesh_housekeeping_ui_refresh(); - /* Mesh time-sync paced evaluation — loop() only runs on - * packet-driven events, so drive the 15-min tick here. */ if (companion_mesh_ptr) { companion_mesh_ptr->timeSyncTick(); } - /* Low-battery auto-shutdown (companion only). Self-throttled - * and compiled out unless the board sets a threshold — no - * extra poll, just a cheap call on the existing tick. */ + /* Low-battery auto-shutdown; compiled out unless the board + * sets a threshold. */ ui_auto_shutdown_check(); - /* V-contact low-battery alert — independent of the UI-driven - * auto-shutdown above (which is a weak no-op on headless - * builds), same 30 s self-throttle. */ + /* The v-contact low-battery alert, separate because the + * shutdown check is a no-op on headless builds. */ vcontact_battery_alert_check(); } - /* Off-main pref mutators (e.g. the autoshutdown CLI handler on - * the sysworkq USB RX path) post this bit and update _prefs in - * memory; we flush to flash here so the synchronous LittleFS - * write doesn't block the originating thread. Multiple posts - * coalesce into one write of the latest _prefs values. */ + /* Prefs changed off-main: the flash write happens here, and posts + * coalesce into one write. */ if ((events & MESH_EVENT_PREFS_DIRTY) && companion_mesh_ptr) { save_prefs_to_flash(); } #endif - /* Off-main RTC write request (gps_fix_callback runs in modem_chat - * context — see request_rtc_save()). Perform the blocking I2C - * write here on the main thread instead. */ + /* See pending_rtc_epoch. */ if (events & MESH_EVENT_RTC_SAVE) { zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); #ifdef ZEPHCORE_LORA - /* GPS just set the clock — arm the mesh time-sync drift envelope - * and activate the deferred v-contact / flush buffered notices. */ + /* GPS set the clock: arm the time-sync drift envelope and + * activate a deferred v-contact. */ if (companion_mesh_ptr) { companion_mesh_ptr->noteGPSTimeSync(); companion_mesh_ptr->vcontactClockSynced(); @@ -684,7 +561,6 @@ static void mesh_event_loop(void) } } -/* Housekeeping timer callback - signals event to wake mesh loop periodically */ static void housekeeping_timer_fn(struct k_timer *timer) { ARG_UNUSED(timer); @@ -692,24 +568,21 @@ static void housekeeping_timer_fn(struct k_timer *timer) } #ifdef ZEPHCORE_LORA -/* LoRa RX callback - called from ISR context when packet received */ +/* ISR context */ static void lora_rx_callback(void *user_data) { ARG_UNUSED(user_data); - /* Signal event to wake mesh loop immediately */ k_event_post(&mesh_events, MESH_EVENT_LORA_RX); } -/* LoRa TX complete callback - called from work queue when async TX finishes */ +/* Driver work queue, when an async TX finishes */ static void lora_tx_done_callback(void *user_data) { ARG_UNUSED(user_data); - /* Signal event to wake mesh loop for TX completion handling */ k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE); } -/* TX drain — Dispatcher queued a packet with a delay. - * Schedule a precise wake so checkSend runs when the delay expires. */ +/* The dispatcher queued a delayed packet: wake when the delay expires. */ static void tx_drain_work_fn(struct k_work *work) { ARG_UNUSED(work); @@ -731,9 +604,7 @@ static ZephyrDataStore data_store(rtc_clock); #ifdef ZEPHCORE_LORA static mesh::ZephyrBoard zephyr_board; -/* Radio is constructed with no prefs pointer; main() binds it to - * companion_mesh.prefs via setPrefs() before companion_mesh.begin(). */ - +/* main() binds the radio to companion_mesh.prefs (setPrefs) before begin(). */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); static mesh::LoRaRadio lora_radio(lora_dev, zephyr_board); @@ -749,20 +620,17 @@ static StaticPoolPacketManager packet_mgr; static CompanionMesh companion_mesh(lora_radio, ms_clock, zephyr_rng, rtc_clock, packet_mgr, mesh_tables, data_store); -/* Defined here (after data_store + companion_mesh_ptr statics) and - * forward-declared near the top of the file so mesh_event_loop() can - * call it without the static decls being in scope. */ static void save_prefs_to_flash(void) { data_store.savePrefs(companion_mesh_ptr->prefs); } -/* ========== Companion text CLI ========== - * One CommonCLI instance shared by two front-ends: the USB CDC text sideband - * (its '<' sync byte separates binary V3 frames from text; BLE NUS has no - * such prefix, so a direct BLE text sideband can't be disambiguated) and the - * v-contact chat, which carries CLI lines inside the binary protocol on - * either transport — so this block is NOT gated on ZEPHCORE_USB_STACK. */ +/* ========== Companion CLI ========== + * The CommonCLI behind CompanionMesh::handleCommand, whose front-ends are the + * app's CMD_RUN_CLI_COMMAND, the USB text console (the '<' sync byte tells V3 + * frames from text; BLE NUS has no such marker, so it has no text console), + * the v-contact chat, and over-the-air TXT_TYPE_CLI_COMMAND. Not gated on + * ZEPHCORE_USB_STACK. */ class CompanionCLICallbacks : public CommonCLICallbacks { public: @@ -772,10 +640,8 @@ public: const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } const char* getRole() override { return "companion"; } - /* CLI `erase`. Re-stamp the init marker on success, exactly as - * factoryReset() does for the BLE opcode path: the format takes - * /lfs/_zc_init with it, so without this the post-reboot first-boot check - * sees "no marker, no prefs" and runs a second, pointless full format. */ + /* CLI `erase`. Re-stamp the init marker as factoryReset() does, or the + * next boot sees no marker and formats a second time. */ bool formatFileSystem() override { bool ok = data_store.formatFileSystem(); if (ok) { @@ -784,10 +650,7 @@ public: return ok; } - /* Advert — the companion can originate its own self-advert. delay_millis is - * unused (companion sends flood at 0 ms / zero-hop immediately, matching the - * app-triggered path); previously a no-op stub, so `advert` from the CLI - * reported success but transmitted nothing. */ + /* Same path as the app's advert command; delay_millis is unused. */ void sendSelfAdvertisement(int delay_millis, bool flood) override { (void)delay_millis; companion_mesh.sendSelfAdvert(flood); @@ -795,10 +658,8 @@ public: void updateAdvertTimer() override {} void updateFloodAdvertTimer() override {} - /* Reboot gate — true only when the app has been told everything we owe it. - * A held-back v-contact delivery-ack counts as outstanding even though it - * has not reached the TX queue yet, so a `reboot` typed into the v-contact - * waits for its own ack before resetting. */ + /* Reboot gate: true once the app has everything we owe it, including a + * held-back v-contact ack, so a `reboot` typed there waits for its ack. */ bool transportTxIdle() override { if (companion_mesh.vcontactConfirmPending() || k_msgq_num_used_get(&pending_reply) != 0) { @@ -812,12 +673,12 @@ public: return zephcore_ble_tx_idle(); } - /* Log control — no log file on companion. */ + /* No log file on a companion. */ void setLoggingOn(bool enable) override { (void)enable; } void eraseLogFile() override {} void dumpLogFile() override {} - /* TX power — Zephyr LoRa driver has no runtime API; log only (matches repeater). */ + /* No runtime TX-power API in the LoRa driver; log only, as the repeater. */ void setTxPower(int8_t power_dbm) override { LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm); } @@ -890,9 +751,7 @@ public: return companion_mesh.getMeshTimeSync(); } - /* GPS control — companion delegates to the GPS manager (same as repeater). - * Without these, the base CommonCLICallbacks stubs make the CLI report - * "off" / "gps toggle not found" regardless of real GPS state. */ + /* GPS, through the GPS manager as on the repeater. */ bool setGpsEnabled(bool enabled) override { if (!gps_is_available()) return false; gps_enable(enabled); @@ -928,9 +787,7 @@ public: } } - /* Stats — same JSON format as the repeater CLI (StatsFormatHelper), so - * `stats-core` / `stats-radio` / `stats-packets` work over USB and the - * v-contact chat instead of the base-class "not available" stubs. */ + /* stats-core / stats-radio / stats-packets, as the repeater prints them. */ void formatStatsReply(char* reply) override { StatsFormatHelper::formatCoreStats(reply, zephyr_board, ms_clock, companion_mesh.getErrFlags(), &packet_mgr); @@ -951,15 +808,9 @@ static ClientACL companion_acl; /* unused by CommonCLI but required by construc static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl, &companion_mesh.prefs, &companion_cli_cbs); -/* Radio reconfigure, reached from the app protocol (CMD_SET_RADIO_PARAMS / - * CMD_SET_RADIO_TX_POWER). Defined here rather than up with the other radio - * callbacks because it needs companion_cli, which needs the mesh and the - * datastore — same reason save_prefs_to_flash() sits below them. - * - * Unlike the CLI's `set radio`, this path applies LIVE: there is no reboot and - * no frozen old preset, so by the time the CAD reset runs the radio is already - * on the new one and the base it stamps is the right one to keep — hence - * preset_pending = false. */ +/* CMD_SET_RADIO_PARAMS / CMD_SET_RADIO_TX_POWER (needs companion_cli, hence + * here). Unlike the CLI's `set radio` this applies live, so the radio is + * already on the new preset when the CAD state resets: preset_pending false. */ static void radio_reconfigure(bool preset_changed) { lora_radio.reconfigure(); @@ -977,10 +828,8 @@ static void radio_reconfigure(bool preset_changed) #endif #if ZEPHCORE_HAS_AUTO_SHUTDOWN -/* Companion-only CLI sideband for the low-battery auto-shutdown threshold. - * Handled here (not in shared CommonCLI) so the pref persists via the - * companion datastore and the command never appears on repeater builds. - * Returns true if the line was a recognised autoshutdown command. */ +/* `get|set autoshutdown`: companion-only, so not in CommonCLI. True if the + * line was one. */ static bool handle_autoshutdown_cli(const char *line, char *reply) { if (strcmp(line, "get autoshutdown") == 0) { @@ -997,8 +846,7 @@ static bool handle_autoshutdown_cli(const char *line, char *reply) while (*arg == ' ') { arg++; } - /* Numbers only: first char must be a digit (rejects sign, letters, - * empty), and nothing but trailing whitespace may follow. */ + /* Digits only, then nothing but whitespace. */ char *end = NULL; long v = strtol(arg, &end, 10); while (*end == ' ' || *end == '\r' || *end == '\n' || *end == '\t') { @@ -1013,10 +861,7 @@ static bool handle_autoshutdown_cli(const char *line, char *reply) return true; } companion_mesh.prefs.auto_shutdown_mv = (uint16_t)v; - /* Defer the flash write to the main thread (this runs on sysworkq via - * the USB RX work handler). A synchronous savePrefs here could race a - * concurrent main-thread save (e.g. a companion-app command handler) - * on the same prefs .tmp file. Posting the event coalesces onto main. */ + /* Coalesced flash write from the event loop. */ k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY); ui_set_auto_shutdown_mv((uint16_t)v); if (v == 0) { @@ -1030,13 +875,11 @@ static bool handle_autoshutdown_cli(const char *line, char *reply) } #endif /* ZEPHCORE_HAS_AUTO_SHUTDOWN */ -/* V-contact battery alert state — defined below with the checker. */ static void vcontact_battery_alert_rearm(void); -/* Resolve the effective v-contact battery-alert threshold in mV. - * 0 = alert off; the 0xFFFF sentinel derives the board default: 200 mV above - * the auto-shutdown cutoff so the alert wins the race against the shutdown - * confirm window (90 s), falling back to 3500 mV on boards without one. */ +/* The v-contact battery-alert threshold in mV, 0 = off. The 0xFFFF default is + * 200 mV above the auto-shutdown cutoff, so the alert beats the 90 s shutdown + * confirm window; 3500 mV on boards without a cutoff. */ static uint16_t vcontact_battery_alert_threshold_mv(void) { uint16_t pref = companion_mesh.prefs.v_battery_alert_mv; @@ -1046,16 +889,11 @@ static uint16_t vcontact_battery_alert_threshold_mv(void) return cutoff ? (uint16_t)(cutoff + 200) : 3500; } -/* Companion-only `v.*` commands (v-contact settings) + the shared help text. - * Runs on the main thread for both front-ends (USB lines are drained from - * companion_cli_queue in the event loop; v-contact lines arrive via - * handleProtocolFrame). PREFS_DIRTY keeps the flash write on the main loop - * regardless. Returns true if the line was recognised. */ +/* The `v.*` commands (v-contact settings) and `help`. True if the line was one. */ static bool handle_vcontact_cli(const char *line, char *reply) { if (strcmp(line, "help") == 0 || strcmp(line, "?") == 0) { - /* No global CLI help exists; list only the companion-specific extras - * and make clear the standard set/get radio+mesh commands also work. */ + /* There is no global help: list the companion extras only. */ strcpy(reply, "Companion extras (standard set/get commands also work):\r\n" #if ZEPHCORE_HAS_AUTO_SHUTDOWN @@ -1085,13 +923,11 @@ static bool handle_vcontact_cli(const char *line, char *reply) bool was = companion_mesh.prefs.v_contact_enabled != 0; companion_mesh.prefs.v_contact_enabled = en ? 1 : 0; k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY); - /* Tell a connected app right away — add or remove the contact. */ if (en && !was) { companion_mesh.vcontactPushAdvert(); } else if (!en && was) { companion_mesh.vcontactPushDeleted(); - /* The app drops the contact, so its flags (favourite / telemetry - * permissions) go with it — same as the app-side delete path. */ + /* The app drops the contact, and its flags with it. */ companion_mesh.prefs.v_contact_flags = 0; } snprintf(reply, CLI_REPLY_SIZE, "OK - v.contact %s", en ? "on" : "off"); @@ -1144,13 +980,10 @@ static bool handle_vcontact_cli(const char *line, char *reply) return false; } -/* Pre-shutdown hook, registered with the UI layer and called on the main - * thread just before a low-battery power-off. If an app is connected, queue a - * live v-contact notice and return true so the UI defers the power-off by a - * short grace (letting the notify→fetch→send round-trip finish). If nobody is - * connected, persist the reason to flash and return false (power off now) — the - * offline queue doesn't survive System OFF, so the flash marker is the only way - * the shutdown gets reported, which it does on the next boot. */ +/* Called just before a low-battery power-off. With an app connected, send a + * v-contact notice and return true (the UI waits a short grace for it). + * Otherwise store the reason in flash, reported on the next boot, since the + * offline queue does not survive System OFF, and return false. */ static bool companion_shutdown_hook(int reason) { const char *msg = (reason == UI_SHUTDOWN_LOW_BATTERY) @@ -1173,14 +1006,13 @@ static bool companion_shutdown_hook(int reason) return false; /* nobody listening — flash marker, power off now */ } -/* Transport-neutral CLI line execution — runs on the MAIN thread only - * (CommonCLI::handleCommand touches mesh state shared with loop()). Both the - * USB text sideband and the v-contact chat funnel through here. `reply` must - * be CLI_REPLY_SIZE (== COMPANION_CLI_REPLY_SIZE). */ +/* The CompanionMesh::handleCommand callback (main thread). The v-contact and + * autoshutdown commands never see a remote sender: handleCommand stops those. */ static_assert(COMPANION_CLI_REPLY_SIZE == CLI_REPLY_SIZE, "companion CLI reply buffer must match CommonCLI reply size"); -static void companion_cli_exec(const char *line, char *reply) +static void companion_cli_exec(const char *line, uint32_t sender_timestamp, + uint8_t reply_hdr_used, char *reply) { reply[0] = '\0'; if (handle_vcontact_cli(line, reply)) { @@ -1191,17 +1023,15 @@ static void companion_cli_exec(const char *line, char *reply) return; } #endif - companion_cli.handleCommand(0, line, reply); + companion_cli.setReplyHeaderUsed(reply_hdr_used); + companion_cli.handleCommand(sender_timestamp, line, reply); } /* ========== V-contact battery alert ========== - * Deliberately NOT hooked into ui_auto_shutdown_check() — that is a weak - * no-op on headless builds. Same proven pattern though: 30 s ADC sample gate - * off the 5 s housekeeping tick, 3-strike confirm so a TX-induced sag can't - * false-trigger, skip while externally powered. One-shot latch per discharge - * cycle; re-arms on charge/recovery (threshold + 150 mV) or threshold change. - * The message rides the offline queue, so it still reaches the app if nothing - * is connected when it fires. */ + * Not in ui_auto_shutdown_check(), which is a no-op on headless builds; same + * pattern: a 30 s sample gate on the housekeeping tick, three strikes so a TX + * sag cannot trigger it, skipped on external power. Once per discharge; it + * re-arms at threshold + 150 mV or on a threshold change. */ static bool vcontact_batt_latched; static uint8_t vcontact_batt_low_count; @@ -1255,15 +1085,12 @@ static void vcontact_battery_alert_check(void) } #if ZEPHCORE_USB_STACK -/* Executes a text-CLI line from USB, reply back over USB CDC. Output mirrors - * the repeater's serial CLI ("\r\n -> \r\n", CRLF) so the - * flasher.meshcore.io serial console renders companion replies identically. - * Runs on the MAIN thread (drained from companion_cli_queue in the event - * loop). */ +/* A USB text-CLI line. The reply is framed like the repeater's serial CLI + * ("\r\n -> \r\n") so web serial consoles render it the same. */ static void companion_cli_run(const char *line) { - char reply[CLI_REPLY_SIZE]; - companion_cli_exec(line, reply); + char reply[COMPANION_CLI_PREFIX_ROOM + CLI_REPLY_SIZE]; + companion_mesh.handleCommand(line, 0, reply); if (reply[0] != '\0') { zephcore_usb_companion_write_text("\r\n -> ", 7); zephcore_usb_companion_write_text(reply, strlen(reply)); @@ -1271,8 +1098,7 @@ static void companion_cli_run(const char *line) zephcore_usb_companion_write_text("\r\n", 2); } -/* USB-adapter callback (runs on sysworkq). Just queue the line and wake the - * main thread — the actual handleCommand happens in companion_cli_run(). */ +/* USB adapter callback (sysworkq): queue the line for companion_cli_run(). */ static void companion_cli_dispatch(const char *line) { struct companion_cli_line c; @@ -1289,48 +1115,36 @@ static void companion_cli_dispatch(const char *line) #endif -/* GPS enable callback - logs state changes - * Note: GPS persistence is handled in CompanionMesh.cpp CMD_SET_CUSTOM_VAR handler */ +/* GPS on/off (persisted by the CMD_SET_CUSTOM_VAR handler) */ static void gps_enable_callback(bool enabled) { LOG_INF("GPS %s", enabled ? "enabled" : "disabled"); ui_set_gps_enabled(enabled); } -/* GPS event callback - called when GPS work handlers need the main thread - * to process a state transition (wake from standby, timeout, etc.). - * Runs from system work queue context — just posts an event, no blocking. */ +/* A GPS state transition for the main thread (from the system work queue). */ static void gps_event_callback(void) { k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION); } -/* GPS fix callback - called when GPS acquires a valid fix. - * Updates mesh node position and RTC. */ +/* A valid fix (GNSS modem_chat thread): set the clock and the node position. */ static void gps_fix_callback(double lat, double lon, int64_t utc_time) { - /* Sync RTC from GPS time */ if (utc_time > 0) { LOG_INF("GPS fix: RTC sync time=%lld", utc_time); rtc_clock.setCurrentTime((uint32_t)utc_time); time_sync_report(TIME_SYNC_GPS); - /* Defer the hardware-RTC write to the main thread — see - * request_rtc_save()/pending_rtc_epoch: blocking I2C here would - * stall NMEA ingest, same hazard as the prefs flash write below. */ request_rtc_save((uint32_t)utc_time); } #ifdef ZEPHCORE_LORA - /* Update node position for mesh advertising — RAM only, no flash - * write. Matches upstream Arduino (sensors.node_lat/lon): the - * position reaches flash only piggybacked on the next savePrefs() - * triggered by an actual settings change. Persisting from here - * would rewrite the full prefs blob on nearly every promoted fix, - * since GPS jitter defeats the exact-double comparison below. */ + /* RAM only, as upstream: GPS jitter would otherwise rewrite prefs on + * nearly every fix. It reaches flash with the next real settings save. */ if (lat != companion_mesh.prefs.node_lat || lon != companion_mesh.prefs.node_lon) { companion_mesh.prefs.node_lat = lat; companion_mesh.prefs.node_lon = lon; - /* Log as integer degrees (Zephyr LOG doesn't support %f by default) */ + /* Zephyr LOG has no %f by default */ int lat_deg = (int)lat; int lon_deg = (int)lon; int lat_frac = (int)((lat - lat_deg) * 1000000); @@ -1341,7 +1155,6 @@ static void gps_fix_callback(double lat, double lon, int64_t utc_time) lat_deg, lat_frac, lon_deg, lon_frac); } - /* Update UI with GPS data */ { struct gps_position gpos; @@ -1380,13 +1193,10 @@ static void bt_ready(int err) int main(void) { - /* Clear any stale bootloader magic from previous sessions */ zephyr_board.clearBootloaderMagic(); - /* USB CDC init up front so the host can enumerate, then block until the - * host opens the port (DTR-high) — event-driven via the shared usbd - * message callback. Unplugged → 1 s timeout, banner is buffered for any - * later attach. */ + /* USB up first so the host can enumerate; wait up to 1 s for the port to + * open (DTR), else the banner is buffered for a later attach. */ #if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \ (IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS)) @@ -1395,18 +1205,10 @@ int main(void) #endif LOG_INF("=== ZephCore starting ==="); - /* Restart-reason notice for the v-contact, queued below once the mesh is - * up. Offline-queue only, so a routine power-on costs nothing over the - * air, and doubles as a power-integrity breadcrumb (dead battery, loose - * contact). The cause is captured, cleared and logged at POST_KERNEL by - * helpers/boot_info.c. - * - * A debugger reset or a low-power wake (the user's own power button) is - * quiet on its own. A VBUS wake from System OFF on nRF52840 reports POR, - * so plugging in a charger still announces itself. - * - * "Restarted:" (10) + every label with hints (238) + shutdown reason (29) - * + NUL. vcontactNotify() truncates to 127 on the RTC-less path. */ + /* Restart-reason notice for the v-contact (cause captured at POST_KERNEL by + * helpers/boot_info.c), queued once the mesh is up. A power-integrity + * breadcrumb; a debugger reset or a button wake is quiet. Size: "Restarted:" + * (10) + every label (238) + shutdown reason (29) + NUL. */ char boot_cause_msg[280]; boot_cause_msg[0] = '\0'; { @@ -1424,12 +1226,8 @@ int main(void) } data_store.begin(); - /* Fold a persisted shutdown reason into the boot notice. Written just - * before a previous low-battery System OFF when no app was connected to - * receive it live (the offline queue doesn't survive power-off). The - * hardware cause on this boot does not say "low battery" (a button wake - * is quiet above, a charger wake reads POR), so without this the reason - * would be lost. */ + /* A low-battery shutdown with no app connected left its reason in flash + * (see companion_shutdown_hook); the hardware cause cannot tell. */ { uint8_t sdr = data_store.takeShutdownReason(); if (sdr == UI_SHUTDOWN_LOW_BATTERY) { @@ -1477,12 +1275,9 @@ int main(void) data_store.writeInitMarker(); } - /* Initialize sensor manager (GPS, environment sensors) */ sensor_manager_init(); - /* Restore wall-clock time from a battery-backed hardware RTC if one is - * present on I2C. Shown tagged "L" (local) until the next GPS/app/CLI - * sync; no-op on boards without an RTC. */ + /* Wall-clock time from a battery-backed RTC, if one answers on I2C. */ { uint32_t rtc_epoch; if (zephcore_rtc_restore(&rtc_epoch)) { @@ -1490,9 +1285,7 @@ int main(void) } } - /* Initialize UI subsystem (buttons, buzzer, display). - * If display is present, ui_init() handles display init + auto-off. - * If no display, fall back to raw OLED sleep for power saving. */ + /* Buttons, buzzer, display; without a display UI, put any OLED to sleep. */ ui_init(); #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) joystick_ui_hooks_register(&joystick_ui_task, joystick_signal_refresh, joystick_signal_tx); @@ -1501,7 +1294,6 @@ int main(void) oled_sleep(); #endif - /* Register GPS callbacks (GPS state restored from prefs later) */ if (gps_is_available()) { LOG_INF("GPS available"); gps_set_enable_callback(gps_enable_callback); @@ -1509,47 +1301,35 @@ int main(void) gps_set_event_callback(gps_event_callback); } - /* Log environment sensor availability */ if (env_sensors_available()) { LOG_INF("Environment sensors available"); } #ifdef ZEPHCORE_LORA - /* Initialize prefs with defaults via the single source of truth - * (initNodePrefs) so every field — including new ones added later — gets - * its proper default. A hand-maintained subset here silently drifts: any - * field not listed defaults to 0, and on upgrade the past-EOF read in - * loadPrefs then keeps that 0 instead of the real default (this is what - * zeroed probe_interval / cad_auto and, earlier, the GPS settings). */ + /* Defaults from initNodePrefs() only: a field defaulted here instead is + * missed by its other callers, and a missing default reads back as 0 on + * upgrade (that zeroed probe_interval and cad_auto once). The one override + * lets a board's Kconfig GPS duty win over the 300 s companion figure. */ initNodePrefs(&companion_mesh.prefs); - /* Companion-specific overrides vs. initNodePrefs defaults. auto_shutdown_mv - * used to be set here too; it now comes from initNodePrefs() itself, which - * is what the comment above asks for — a value listed only here is invisible - * to every other caller of initNodePrefs(). gps_interval stays because - * initNodePrefs() hardcodes the 300 s companion figure while repeaters run a - * much longer duty; this line is how a board's Kconfig value wins. */ - companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 5-min duty cycle (0=always-on) */ + companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 0 = always on */ - /* Load prefs from storage */ data_store.loadPrefs(companion_mesh.prefs); - /* Apply saved BLE PIN (0 = use Kconfig default) */ + /* Saved BLE PIN (0 = Kconfig default) */ if (companion_mesh.prefs.ble_pin >= 100000 && companion_mesh.prefs.ble_pin <= 999999) { zephcore_ble_set_passkey(companion_mesh.prefs.ble_pin); LOG_INF("BLE passkey loaded from prefs: %06u", companion_mesh.prefs.ble_pin); } - /* Bind the radio to the live prefs before begin(): the radio reads - * freq/bw/sf/cr/power through this pointer from Radio::begin() on. */ + /* The radio reads its preset through this from Radio::begin() on. */ lora_radio.setPrefs(&companion_mesh.prefs); - /* Load contacts and channels */ data_store.loadContacts(&companion_mesh); data_store.loadChannels(&companion_mesh); - /* Add Public channel if no channels loaded (first boot) */ + /* First boot: the Public channel */ if (companion_mesh.getNumChannels() == 0) { - // Public channel PSK: "izOH6cXN6mrJ5e26oRXNcg==" decoded + // "izOH6cXN6mrJ5e26oRXNcg==" decoded static const uint8_t public_psk[16] = { 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 @@ -1559,9 +1339,7 @@ int main(void) LOG_INF("Added default Public channel"); } - /* Load or generate identity. First-boot keygen runs the layered - * entropy mixer + Ed25519 derive + reserved-prefix guard inside - * ZephyrRNG::generateFirstBootIdentity. */ + /* First boot generates the identity (ZephyrRNG::generateFirstBootIdentity). */ mesh::LocalIdentity self_identity; if (!data_store.loadMainIdentity(self_identity)) { mesh::ZephyrRNG::generateFirstBootIdentity(self_identity); @@ -1569,8 +1347,7 @@ int main(void) } companion_mesh.self_id = self_identity; - /* Set default node name from first 4 bytes of public key if not set. - * Must happen after identity load so pub_key is available. */ + /* Default node name: the first 4 bytes of the public key. */ if (companion_mesh.prefs.node_name[0] == '\0') { snprintf(companion_mesh.prefs.node_name, sizeof(companion_mesh.prefs.node_name), "%02X%02X%02X%02X", @@ -1578,7 +1355,6 @@ int main(void) self_identity.pub_key[2], self_identity.pub_key[3]); } - /* Set callbacks */ companion_mesh.setWriteFrameCallback(write_frame); companion_mesh.setPushCallback(push_callback); companion_mesh.setBatteryCallback(get_battery_mv); @@ -1590,30 +1366,22 @@ int main(void) companion_mesh.setPinChangeCallback([](uint32_t new_pin) { zephcore_ble_set_passkey(new_pin); }); - /* V-contact chat lines run the same CLI as the USB text sideband. */ companion_mesh.setCLICallback(companion_cli_exec); companion_mesh_ptr = &companion_mesh; - /* Set LoRa callbacks for event-driven packet processing */ lora_radio.setRxCallback(lora_rx_callback, nullptr); lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); companion_mesh.setTxQueuedCallback(tx_queued_callback, nullptr); - /* Start mesh */ companion_mesh.begin(); - /* Queue the restart-reason notice from the v-contact. Deferred to here so - * begin() has derived the v-contact key, prefs (enable flag) are loaded, - * and the hardware-RTC restore above gives the message a sane timestamp. - * It sits in the offline queue until the first app connect/sync. */ + /* After begin(), which derives the v-contact key. */ if (boot_cause_msg[0] != '\0') { companion_mesh.vcontactNotify(boot_cause_msg); } - /* Physical mounting orientation, from prefs. This is the earliest it can - * run — ui_init() brings the panel up before prefs are loaded, so the - * splash may flash upright for a moment before the remap lands. A panel - * that cannot rotate logs a warning and stays native. */ + /* Mounting orientation, as early as prefs allow (the splash may show + * upright first). A panel that cannot rotate stays native. */ zephcore_input_set_flipped(companion_mesh.prefs.input_rotate != 0); #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) if (companion_mesh.prefs.display_rotate) { @@ -1621,7 +1389,6 @@ int main(void) } #endif - /* Push initial state to UI display */ ui_set_node_name(companion_mesh.prefs.node_name); ui_set_radio_params( lora_radio.getActiveFrequencyHz(), @@ -1645,14 +1412,11 @@ int main(void) ui_set_gps_enabled(companion_mesh.prefs.gps_enabled != 0); ui_set_ble_enabled(companion_mesh.prefs.ble_disabled != 1); /* BLE starts advertising at boot */ - /* Restore offgrid mode (client repeat) state from persisted prefs */ ui_set_offgrid_mode(companion_mesh.prefs.client_repeat != 0); LOG_INF("offgrid mode: %s (from prefs)", companion_mesh.prefs.client_repeat ? "on" : "off"); - /* Restore the notification mode from persisted prefs, then play the - * startup chime only if sound is on. buzzer_init() defaults to - * quiet=true, so the saved preference must always be applied. */ + /* buzzer_init() starts quiet, so the saved mode is always applied. */ #if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER) { uint8_t bmode = zephcore_buzzer_mode_from_prefs(companion_mesh.prefs.buzzer_quiet); @@ -1663,10 +1427,8 @@ int main(void) ui_play_startup_chime(); #endif - /* Restore LED enabled/disabled state from persisted prefs. - * If LEDs were disabled, stop the heartbeat LED cycle. Straight to the gate - * rather than via ui_set_leds_disabled(): the gate also governs the LoRa TX - * LED and is linked into every build, UI or not. */ + /* Straight to the LED gate: it also governs the TX LED and exists in every + * build, UI or not. */ bool leds_off = companion_mesh.prefs.leds_disabled != 0; zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(companion_mesh.prefs.leds_radio_mode); @@ -1674,16 +1436,10 @@ int main(void) ui_set_heartbeat_led(!leds_off); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); - /* Restore GPS state from persisted prefs. - * GPS hardware is powered at boot (bootloader/pull-up). - * First, ensure power state matches prefs (powers off if disabled). - * Then, if prefs say enabled, start the GPS state machine. */ + /* The GPS is powered at boot: match its power to prefs, then start the + * state machine if enabled. */ if (gps_is_available()) { - /* Apply persisted GPS duty interval (0 = always on) before the - * state machine starts. */ gps_set_poll_interval_sec(companion_mesh.prefs.gps_interval); - - /* Explicitly set power state at boot (handles disabled case) */ gps_ensure_power_state(companion_mesh.prefs.gps_enabled); if (companion_mesh.prefs.gps_enabled) { @@ -1701,10 +1457,8 @@ int main(void) companion_mesh.prefs.probe_interval, companion_mesh.prefs.cad_busycap, companion_mesh.prefs.cad_base); - /* Write back the (offset, base) actually in force. setCadParams() may - * have re-anchored the offset across a base-table change, and the pair - * has to be persisted for the NEXT upgrade to have an anchor of its own. - * Guarded so a node whose base has not moved never writes flash at boot. */ + /* Persist the (offset, base) in force if setCadParams() re-anchored it + * across a base-table change, so the next upgrade has an anchor too. */ if (companion_mesh.prefs.cad_base != lora_radio.cadBasePeak() || companion_mesh.prefs.cad_offset != lora_radio.getCadOffset()) { companion_mesh.prefs.cad_offset = lora_radio.getCadOffset(); @@ -1712,10 +1466,8 @@ int main(void) data_store.savePrefs(companion_mesh.prefs); } - /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a - * zero-terminated list; a rejected set (SF/BW changed since it was - * saved) just leaves the feature off, and non-LR2021 radios report it - * unsupported. */ + /* LR2021 side detectors (multi-SF RX). A set rejected for the current + * SF/BW leaves them off; other radios report them unsupported. */ { uint8_t n = 0; while (n < EXTRA_SF_MAX && companion_mesh.prefs.extra_sf[n] != 0) n++; @@ -1731,13 +1483,11 @@ int main(void) lora_radio.isInRecvMode(), lora_radio.isTxActive()); - /* Restore runtime ADC multiplier override (0 = keep DT default) */ + /* ADC multiplier override (0 = devicetree default) */ zephyr_board.setAdcMultiplier(companion_mesh.prefs.adc_multiplier); - /* Initialize mesh event object */ k_event_init(&mesh_events); - /* Initialize UI mesh actions module (pass mesh objects for deferred actions) */ ui_mesh_actions_init(&mesh_events, MESH_EVENT_UI_ACTION, &companion_mesh, &data_store, &lora_radio, &zephyr_board, &rtc_clock); @@ -1747,23 +1497,15 @@ int main(void) #endif #endif - /* Initialize BLE adapter (registers auth callbacks) */ zephcore_ble_init(&ble_cbs); - /* - * USB CDC ACM companion transport. - * Enabled when logging is on (debug builds) OR when explicitly requested - * via CONFIG_ZEPHCORE_COMPANION_USB (e.g. prod builds on USB-capable boards). - */ #if ZEPHCORE_USB_STACK zephcore_usb_companion_init(&mesh_events, MESH_EVENT_BLE_RX, &zephyr_board); - /* Mirror BLE connect/disconnect UI + cleanup for USB sessions. */ zephcore_usb_companion_set_session_start_cb(usb_on_session_start); zephcore_usb_companion_set_session_end_cb(usb_on_session_end); - /* Resume the contact pump when the USB TX ring drains (≈ BLE on_tx_idle). */ zephcore_usb_companion_set_tx_drain_cb(usb_on_tx_drain); - /* Text CLI sideband — activates when first byte is not '<'. */ + /* The text CLI starts when the first byte is not '<'. */ zephcore_usb_companion_set_cli_line_cb(companion_cli_dispatch); #endif @@ -1772,36 +1514,11 @@ int main(void) LOG_ERR("bt_enable failed"); } #else - /* No BLE controller — TCP companion transport starts itself. - * zephcore_ble_start() is provided by LinuxTCPTransport.c when - * CONFIG_ZEPHCORE_TRANSPORT_TCP=y. */ + /* No BLE controller: LinuxTCPTransport.c provides zephcore_ble_start(). */ zephcore_ble_start(companion_mesh.getDeviceName()); #endif - /* - * FULLY EVENT-DRIVEN architecture: main thread runs mesh event loop. - * Mesh loop wakes IMMEDIATELY on events - NO POLLING for TX completion! - * - * Event sources: - * - MESH_EVENT_LORA_RX: LoRa packet received (from RX async callback) - * - MESH_EVENT_LORA_TX_DONE: LoRa TX complete (from TX poll work -> callback) - * - MESH_EVENT_HOUSEKEEPING: Periodic maintenance (noise floor, etc.) - * - MESH_EVENT_BLE_RX: BLE/USB frame (or USB text-CLI line) ready to parse. - * The BLE callback / USB adapter only assemble + queue the frame off-main; - * process_companion_rx() (and companion_cli_run()) parse it HERE on the - * main thread so mesh-state mutation never races loop(). - * - MESH_EVENT_TX_DRAIN: outbound LoRa packet queued — wakes loop() to drain - * - MESH_EVENT_UI_ACTION / GPS_ACTION / PREFS_DIRTY: deferred work - * from non-main threads - * - * Other processing still uses work queues: - * - BLE TX: write_frame -> tx_drain_work (event-driven via notify callback) - * - Contact iteration: MESH_EVENT_CONTACT_ITER -> run_contact_iteration() - * on the main thread, paced by the BLE/USB tx-idle callbacks - * - * TX completion is now event-driven: radio polls at 5ms intervals internally - * and fires callback when done. Much better than 100ms main loop polling! - */ + /* The main thread becomes the mesh event loop (see MESH_EVENT_*). */ #ifdef ZEPHCORE_LORA mesh_event_loop(); /* Never returns */ #else