diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3bd816c..c7e82dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1079,7 +1079,7 @@ Over USB CDC (and native-Linux TCP): framed with a length prefix — `[2B LE len ### Key Command Opcodes (phone → device) -The full set (~50 opcodes, `0x01`–`0x41`) is defined at the top of `app/CompanionMesh.cpp`; values match the Arduino MeshCore companion protocol. A sample: +The full set (~50 opcodes, `0x01`–`0x42`) is defined at the top of `app/CompanionMesh.cpp`; values match the Arduino MeshCore companion protocol. A sample: | Opcode | Name | Payload | |--------|------|---------| diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index e340d0d..874e984 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -84,6 +84,7 @@ LOG_MODULE_REGISTER(zephcore_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #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 @@ -115,6 +116,7 @@ LOG_MODULE_REGISTER(zephcore_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #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) @@ -210,7 +212,7 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes _dirty_channels_expiry = 0; memset(_send_scope.key, 0, sizeof(_send_scope.key)); _send_scope_force_unscoped = false; - _vcontact_cli_cb = nullptr; + _cli_exec_cb = nullptr; memset(_vcontact_pubkey, 0, sizeof(_vcontact_pubkey)); _vcontact_lastmod = 0; memset(_vcontact_recent_ts, 0, sizeof(_vcontact_recent_ts)); @@ -1156,7 +1158,8 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) * 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) { + if (txt_type != TXT_TYPE_PLAIN && txt_type != TXT_TYPE_CLI_DATA && + txt_type != TXT_TYPE_CLI_COMMAND) { sendPacketError(ERR_UNSUPPORTED); return true; } @@ -1242,10 +1245,10 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) } LOG_INF("vcontact CLI: '%s'", line); - char reply[VCONTACT_CLI_REPLY_SIZE]; + char reply[COMPANION_CLI_REPLY_SIZE]; reply[0] = '\0'; - if (_vcontact_cli_cb) { - _vcontact_cli_cb(line, reply); + if (_cli_exec_cb) { + _cli_exec_cb(line, reply); } else { strcpy(reply, "CLI not available"); } @@ -2637,16 +2640,18 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) 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)) { + 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) { + 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, text, est_timeout); + result = sendCommandData(*contact, msg_timestamp, attempt, txt_type, text, + est_timeout); } else { result = sendMessage(*contact, msg_timestamp, attempt, text, expected_ack, est_timeout); } @@ -2919,7 +2924,13 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) static const uint8_t version[20] = FIRMWARE_VERSION; // injected by CMakeLists.txt uint8_t rsp[82]; rsp[0] = PACKET_DEVICE_INFO; - rsp[1] = 13; // FIRMWARE_VER_CODE - v13 = CMD_SEND_ANON_REQ to non-contact pubkey (transient anon contacts) + /* 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 @@ -3727,6 +3738,48 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) } 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(); diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index cff007e..dc1ab4b 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -91,11 +91,12 @@ typedef void (*RadioReconfigureCallback)(void); /* BLE PIN change callback */ typedef void (*PinChangeCallback)(uint32_t new_pin); -/* V-contact CLI execution callback — runs a text-CLI line and fills `reply` - * (buffer is VCONTACT_CLI_REPLY_SIZE). Registered by main_companion so the - * v-contact chat reuses the same CommonCLI instance as the USB text CLI. */ -#define VCONTACT_CLI_REPLY_SIZE 256 -typedef void (*VContactCLICallback)(const char *line, char *reply); +/* 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. */ +#define COMPANION_CLI_REPLY_SIZE 256 +typedef void (*CompanionCLICallback)(const char *line, char *reply); /** * CompanionMesh: Application layer for ZephCore Companion device @@ -170,7 +171,7 @@ public: * 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 setVContactCLICallback(VContactCLICallback cb) { _vcontact_cli_cb = cb; } + void setCLICallback(CompanionCLICallback cb) { _cli_exec_cb = cb; } 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. */ @@ -510,7 +511,7 @@ private: * 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. */ - VContactCLICallback _vcontact_cli_cb; + CompanionCLICallback _cli_exec_cb; uint8_t _vcontact_pubkey[PUB_KEY_SIZE]; uint32_t _vcontact_lastmod; /* Dedupe app resends: a retry reuses the message timestamp (only the diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 3548b34..b000a6c 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -860,7 +860,11 @@ void RepeaterMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender memcpy(&sender_timestamp, data, 4); uint8_t flags = (data[4] >> 2); - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { + /* TXT_TYPE_CLI_COMMAND (v1.18+) is handled exactly like TXT_TYPE_CLI_DATA + * here: both reach this block only behind client->isAdmin(), and both + * are covered by the monotonic sender_timestamp / is_retry gates below. */ + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || + flags == TXT_TYPE_CLI_COMMAND)) { LOG_DBG("onPeerDataRecv: unsupported text type: flags=%02x", flags); } else if (sender_timestamp >= client->last_timestamp) { bool is_retry = (sender_timestamp == client->last_timestamp); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 69e7049..d6b6970 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -601,7 +601,11 @@ void RoomServerMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int send memcpy(&sender_timestamp, data, 4); uint8_t flags = (data[4] >> 2); - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { + /* TXT_TYPE_CLI_COMMAND (v1.18+) is handled exactly like TXT_TYPE_CLI_DATA + * here: both stay behind client->isAdmin() below, and both are covered + * by the monotonic sender_timestamp / is_retry gates. */ + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || + flags == TXT_TYPE_CLI_COMMAND)) { LOG_DBG("onPeerDataRecv: unsupported text type: flags=%02x", flags); } else if (sender_timestamp >= client->last_timestamp) { bool is_retry = (sender_timestamp == client->last_timestamp); @@ -618,7 +622,7 @@ void RoomServerMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int send uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; bool send_ack; - if (flags == TXT_TYPE_CLI_DATA) { // admin CLI over the air + if (flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND) { // admin CLI over the air if (client->isAdmin()) { if (is_retry) { temp[5] = 0; diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index 62bb95b..171116e 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -318,6 +318,13 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender } 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; @@ -533,14 +540,14 @@ int BaseChatMesh::sendMessage(const ContactInfo &recipient, uint32_t timestamp, } int BaseChatMesh::sendCommandData(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, - const char *text, uint32_t &est_timeout) + 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) | (TXT_TYPE_CLI_DATA << 2); + temp[4] = (attempt & 3) | (txt_type << 2); memcpy(&temp[5], text, text_len + 1); mesh::Packet *pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, diff --git a/zephcore/helpers/BaseChatMesh.h b/zephcore/helpers/BaseChatMesh.h index 33b439c..415b416 100644 --- a/zephcore/helpers/BaseChatMesh.h +++ b/zephcore/helpers/BaseChatMesh.h @@ -193,8 +193,11 @@ public: 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); - int sendCommandData(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, const char *text, - 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 diff --git a/zephcore/helpers/TxtDataHelpers.h b/zephcore/helpers/TxtDataHelpers.h index f301c80..4b58da0 100644 --- a/zephcore/helpers/TxtDataHelpers.h +++ b/zephcore/helpers/TxtDataHelpers.h @@ -10,8 +10,9 @@ #include #define TXT_TYPE_PLAIN 0 // a plain text message -#define TXT_TYPE_CLI_DATA 1 // a CLI command +#define TXT_TYPE_CLI_DATA 1 // a CLI command -or- reply #define TXT_TYPE_SIGNED_PLAIN 2 // plain text, signed by sender +#define TXT_TYPE_CLI_COMMAND 3 // a CLI command (explicitly), v1.18+ #define DATA_TYPE_RESERVED 0x0000 // reserved for future use #define DATA_TYPE_DEV 0xFFFF // developer namespace for experimenting with group/channel datagrams diff --git a/zephcore/helpers/ui-joystick/screens/contacts.cpp b/zephcore/helpers/ui-joystick/screens/contacts.cpp index ec6c9d0..114fa47 100644 --- a/zephcore/helpers/ui-joystick/screens/contacts.cpp +++ b/zephcore/helpers/ui-joystick/screens/contacts.cpp @@ -1113,7 +1113,9 @@ bool RepeaterAdminScreen::sendCLI(const char *cmd) } uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); uint32_t est_timeout = 0; - int result = mesh->sendCommandData(*contact, ts, 0, cmd, est_timeout); + /* TXT_TYPE_CLI_DATA, not TXT_TYPE_CLI_COMMAND: this talks to repeaters of + * any vintage, and type 3 is only understood from v1.18 on. */ + int result = mesh->sendCommandData(*contact, ts, 0, TXT_TYPE_CLI_DATA, cmd, est_timeout); if (result == MSG_SEND_FAILED) { _task->showAlert("Send failed", 1500); return false; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 4964b34..73ebdf0 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -1090,9 +1090,9 @@ static bool companion_shutdown_hook(int reason) /* 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 (== VCONTACT_CLI_REPLY_SIZE). */ -static_assert(VCONTACT_CLI_REPLY_SIZE == CLI_REPLY_SIZE, - "v-contact reply buffer must match CommonCLI reply size"); + * be CLI_REPLY_SIZE (== COMPANION_CLI_REPLY_SIZE). */ +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) { @@ -1510,7 +1510,7 @@ int main(void) zephcore_ble_set_passkey(new_pin); }); /* V-contact chat lines run the same CLI as the USB text sideband. */ - companion_mesh.setVContactCLICallback(companion_cli_exec); + companion_mesh.setCLICallback(companion_cli_exec); companion_mesh_ptr = &companion_mesh; /* Set LoRa callbacks for event-driven packet processing */