diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index a94c758..37cbf4a 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -640,6 +640,7 @@ else() if(CONFIG_LOG OR CONFIG_ZEPHCORE_COMPANION_USB) target_sources(app PRIVATE adapters/usb/ZephyrCompanionUSB.cpp + helpers/CommonCLI.cpp # backs the USB-only text CLI dispatch ) if(NOT CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT AND (CONFIG_USB_CDC_ACM OR CONFIG_USBD_CDC_ACM_CLASS)) target_sources(app PRIVATE diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp index 18ef5dc..b079bd0 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp @@ -40,12 +40,15 @@ LOG_MODULE_REGISTER(zephcore_usb, CONFIG_ZEPHCORE_USB_LOG_LEVEL); #define USB_FRAME_TX_SYNC '>' enum usb_rx_state { - USB_RX_IDLE = 0, /* waiting for '<' sync byte */ + USB_RX_IDLE = 0, /* waiting for '<' sync byte or first text byte */ USB_RX_LEN_LO, /* got sync, waiting len LSB */ USB_RX_LEN_HI, /* got len LSB, waiting len MSB */ - USB_RX_PAYLOAD, /* accumulating payload */ + USB_RX_PAYLOAD, /* accumulating V3 payload */ + USB_RX_TEXT, /* text CLI line mode — accumulate until CR/LF */ }; +#define USB_TEXT_LINE_MAX 128 + /* USB CDC state */ static const struct device *usb_dev; static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE]; @@ -75,6 +78,33 @@ static void (*s_session_end_cb)(void); /* TX-drained callback (mirrors BLE on_tx_idle) — re-kicks the contact pump. */ static void (*s_tx_drain_cb)(void); +/* CLI text line callback — fired when a complete line arrives in text mode. */ +static void (*s_cli_line_cb)(const char *line); +static char usb_text_line[USB_TEXT_LINE_MAX]; +static uint8_t usb_text_len; + +/* Banner shown once per text-CLI session, matching the repeater's serial CLI so + * the flasher.meshcore.io console renders identically. Emitted only after text + * mode is detected (first printable byte) — never on the binary V3 path, so an + * official client connected over USB never receives stray text. CRLF endings: + * the console's LineBreakTransformer only splits on "\r\n". */ +#define USB_CLI_BANNER "\r\n=== ZephCore Companion ===\r\n" +static bool usb_text_banner_sent; + +/* True when the USB interface was claimed by the text CLI (not a binary V3 + * companion app). While set, main suppresses binary frame/push output to USB so + * the serial console only ever sees text. Set on the claiming transition, reset + * when the session ends. */ +static bool usb_session_is_text; + +/* Echo text-mode bytes back via the interrupt-driven TX ring (NOT uart_poll_out, + * unlike the repeater) so echo stays off the polling path and ordered with the + * reply. Only ever called from the text branches, so binary sessions see no echo. */ +static inline void usb_cli_echo(const char *s, size_t n) +{ + zephcore_usb_companion_write_text(s, n); +} + /* Work items */ static void usb_rx_work_fn(struct k_work *work); @@ -126,6 +156,39 @@ static void usb_uart_isr(const struct device *dev, void *user_data) } } +/* Claim the interface for USB on the first inbound traffic of a session — + * a binary frame or a complete CLI line — mirroring BLE, which claims on + * connect. The official client opens with CMD_DEVICE_QUERY (0x16), not + * CMD_APP_START, so the claim is gated on any first traffic, not a specific + * opcode. Returns true when USB owns the interface and the caller should + * process the input; false while a BLE session holds it. */ +static bool usb_claim_active(uint8_t log_tag, bool is_text) +{ + if (zephcore_ble_get_active_iface() != ZEPHCORE_IFACE_USB) { + /* try_claim succeeds when idle or already USB (reconnect) and fails + * only while a BLE session is live, so USB can't steal it — and the + * compare-and-set can't race a concurrent BLE claim. */ + if (zephcore_ble_iface_try_claim(ZEPHCORE_IFACE_USB)) { + /* Record session kind on the claiming transition so main can + * suppress binary output for a text-CLI session. */ + usb_session_is_text = is_text; + zephcore_ble_set_enabled(false); + LOG_INF("usb_rx: first traffic 0x%02x → IFACE_USB (%s)", log_tag, + is_text ? "text" : "binary"); + /* New USB session — mirror BLE's on-connect UI notification + * (Arduino shows "connected" for serial transports too). Fires + * once per session: try_claim only returns true on NONE→USB. */ + if (s_session_start_cb) { + s_session_start_cb(); + } + } else { + LOG_INF("usb_rx: traffic 0x%02x ignored, BLE is active", log_tag); + return false; + } + } + return true; +} + /* USB RX work - parses V3 frames from ring buffer */ static void usb_rx_work_fn(struct k_work *work) { @@ -133,24 +196,86 @@ static void usb_rx_work_fn(struct k_work *work) uint8_t byte; - /* Timeout partial frames — if we've been mid-frame too long without - * completing, reset the parser state and resync on the next sync byte. */ + /* Timeout partial input — if we've been mid-frame or mid-text-line too + * long without completing, reset the parser and resync. usb_frame_start_time + * is refreshed on every text byte (below), so for USB_RX_TEXT this acts as an + * inactivity watchdog: it recovers a stray printable byte back to IDLE (so a + * later binary frame parses) without truncating a line that is actively being + * typed. Note this only runs when bytes arrive — it is not a timer and never + * wakes a sleeping node. */ if (usb_rx_st != USB_RX_IDLE && (k_uptime_get_32() - usb_frame_start_time) > USB_FRAME_TIMEOUT_MS) { - LOG_WRN("usb_rx: partial frame timeout (state=%d, expected=%u), resync", + LOG_WRN("usb_rx: partial input timeout (state=%d, expected=%u), resync", usb_rx_st, usb_frame_len); usb_rx_st = USB_RX_IDLE; usb_frame_len = 0; usb_rx_idx = 0; + usb_text_len = 0; } while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) { switch (usb_rx_st) { case USB_RX_IDLE: - /* Ignore stray bytes until the '<' sync byte arrives. */ if (byte == USB_FRAME_RX_SYNC) { + /* V3 binary framing — normal app protocol. */ usb_rx_st = USB_RX_LEN_LO; usb_frame_start_time = k_uptime_get_32(); + } else if (byte >= 0x20 && byte <= 0x7E) { + /* Printable ASCII. Enter text CLI mode only when the + * interface is idle, or we're already in a text session + * (subsequent command lines). Never during a BLE session, + * nor a binary USB companion session — an official client's + * bytes must not be parsed as CLI. The iface read is + * side-effect-free; the claim happens later, on Enter. */ + enum zephcore_iface ifc = zephcore_ble_get_active_iface(); + if (ifc == ZEPHCORE_IFACE_NONE || + (ifc == ZEPHCORE_IFACE_USB && usb_session_is_text)) { + /* Arm the inactivity watchdog so a stray byte resyncs + * to IDLE on its own. */ + usb_text_len = 0; + usb_text_line[usb_text_len++] = (char)byte; + usb_frame_start_time = k_uptime_get_32(); + usb_rx_st = USB_RX_TEXT; + /* Banner once per session, then echo. */ + if (!usb_text_banner_sent) { + usb_text_banner_sent = true; + usb_cli_echo(USB_CLI_BANNER, sizeof(USB_CLI_BANNER) - 1); + } + usb_cli_echo((const char *)&byte, 1); + } + /* else: printable but iface busy/binary — ignore */ + } + /* else: ignore control bytes / noise */ + break; + + case USB_RX_TEXT: + /* Any text byte is activity — refresh the inactivity watchdog. */ + usb_frame_start_time = k_uptime_get_32(); + if (byte == '\n' || byte == '\r') { + /* Line complete — dispatch if non-empty. Claim USB first + * (refused while BLE owns the session) so a CLI command can't + * mutate state out from under an active BLE app. The reply + * (companion_cli_dispatch) emits its own leading CRLF, so the + * Enter keystroke itself is not echoed — matching the repeater. */ + if (usb_text_len > 0) { + usb_text_line[usb_text_len] = '\0'; + if (usb_claim_active((uint8_t)usb_text_line[0], true) && s_cli_line_cb) { + s_cli_line_cb(usb_text_line); + } + } + usb_text_len = 0; + usb_rx_st = USB_RX_IDLE; + } else if (byte == 0x7F || byte == '\b') { + /* Backspace — erase one char on the terminal too. */ + if (usb_text_len > 0) { + usb_text_len--; + usb_cli_echo("\b \b", 3); + } + } else if (byte >= 0x20 && byte <= 0x7E) { + if (usb_text_len < USB_TEXT_LINE_MAX - 1) { + usb_text_line[usb_text_len++] = (char)byte; + usb_cli_echo((const char *)&byte, 1); + } } break; case USB_RX_LEN_LO: @@ -178,34 +303,8 @@ static void usb_rx_work_fn(struct k_work *work) LOG_DBG("usb_rx: frame complete len=%u hdr=0x%02x", payload_len, payload[0]); - /* Claim the interface for USB on the FIRST inbound frame of - * any opcode — mirroring BLE, which claims on connect. The - * official client opens with CMD_DEVICE_QUERY (0x16), not - * CMD_APP_START (0x01); gating the claim on 0x01 dropped that - * first query and the client timed out waiting for a reply. */ - if (zephcore_ble_get_active_iface() != ZEPHCORE_IFACE_USB) { - /* Atomically claim the interface for USB unless BLE - * already owns it. try_claim succeeds when idle or - * already USB (reconnect) and fails only while a BLE - * session is live, so USB can't steal it — and the - * compare-and-set can't race a concurrent BLE claim. */ - if (zephcore_ble_iface_try_claim(ZEPHCORE_IFACE_USB)) { - zephcore_ble_set_enabled(false); - LOG_INF("usb_rx: first frame 0x%02x → IFACE_USB", payload[0]); - /* New USB session — mirror BLE's on-connect UI - * notification (Arduino shows "connected" for serial - * transports too). Fires once per session: try_claim - * only returns true on the NONE→USB transition. */ - if (s_session_start_cb) { - s_session_start_cb(); - } - } else { - LOG_INF("usb_rx: frame 0x%02x ignored, BLE is active", payload[0]); - } - } - - /* Only process if USB is active interface */ - if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { + /* Claim USB (refused while BLE owns the session), then process. */ + if (usb_claim_active(payload[0], false)) { struct { uint16_t len; uint8_t buf[MAX_FRAME_SIZE]; @@ -265,6 +364,9 @@ static void on_dtr_change(bool dtr_active) usb_rx_st = USB_RX_IDLE; usb_frame_len = 0; usb_rx_idx = 0; + usb_text_len = 0; + usb_text_banner_sent = false; /* re-banner the next text session */ + usb_session_is_text = false; /* Discard any pending TX from the closed session. */ uart_irq_tx_disable(usb_dev); @@ -327,6 +429,9 @@ void zephcore_usb_companion_reset_rx(void) usb_rx_st = USB_RX_IDLE; usb_frame_len = 0; usb_rx_idx = 0; + usb_text_len = 0; + usb_text_banner_sent = false; + usb_session_is_text = false; /* Drop any half-sent TX too — the session it belonged to is gone. */ if (usb_dev) { @@ -337,6 +442,11 @@ void zephcore_usb_companion_reset_rx(void) k_spin_unlock(&usb_tx_lock, key); } +bool zephcore_usb_companion_is_text_session(void) +{ + return usb_session_is_text; +} + void zephcore_usb_companion_set_session_start_cb(void (*cb)(void)) { s_session_start_cb = cb; @@ -352,6 +462,22 @@ void zephcore_usb_companion_set_tx_drain_cb(void (*cb)(void)) s_tx_drain_cb = cb; } +void zephcore_usb_companion_set_cli_line_cb(void (*cb)(const char *line)) +{ + s_cli_line_cb = cb; +} + +void zephcore_usb_companion_write_text(const char *text, size_t len) +{ + if (!usb_dev || !text || len == 0) { + return; + } + k_spinlock_key_t key = k_spin_lock(&usb_tx_lock); + ring_buf_put(&usb_tx_ring_buf, (const uint8_t *)text, len); + k_spin_unlock(&usb_tx_lock, key); + uart_irq_tx_enable(usb_dev); +} + void zephcore_usb_companion_init(struct k_event *mesh_events, struct k_work *rx_work, uint32_t mesh_event_ble_rx, diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.h b/zephcore/adapters/usb/ZephyrCompanionUSB.h index 0c6295a..170c271 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.h +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.h @@ -71,6 +71,27 @@ void zephcore_usb_companion_set_session_start_cb(void (*cb)(void)); */ void zephcore_usb_companion_set_session_end_cb(void (*cb)(void)); +/** + * Register a callback fired when a complete text CLI line arrives over USB. + * Activated when the first byte of a session is not the V3 sync byte ('<'). + * The line is null-terminated and has any trailing CR/LF stripped. May be NULL. + */ +void zephcore_usb_companion_set_cli_line_cb(void (*cb)(const char *line)); + +/** + * Write a raw text reply to the USB CDC port (no V3 framing). + * Used to send CLI responses when in text mode. + */ +void zephcore_usb_companion_write_text(const char *text, size_t len); + +/** + * True when the active USB session was opened by the text CLI rather than a + * binary V3 companion app. Main uses this to suppress binary frame/push output + * to USB so the serial console only ever receives text. False when no USB + * session or when a binary companion owns the interface. + */ +bool zephcore_usb_companion_is_text_session(void); + #ifdef __cplusplus } #endif diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 4f31cc8..c985b0e 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -35,13 +35,15 @@ public: virtual void eraseLogFile() = 0; virtual void dumpLogFile() = 0; virtual void setTxPower(int8_t power_dbm) = 0; - virtual void formatNeighborsReply(char* reply) = 0; + /* Repeater-specific — default replies keep companion builds clean. + * Repeater overrides all four; companions get "not available". */ + virtual void formatNeighborsReply(char* reply) { strcpy(reply, "not available"); } virtual void removeNeighbor(const uint8_t* pubkey, int key_len) { - // no-op by default + (void)pubkey; (void)key_len; } - virtual void formatStatsReply(char* reply) = 0; - virtual void formatRadioStatsReply(char* reply) = 0; - virtual void formatPacketStatsReply(char* reply) = 0; + virtual void formatStatsReply(char* reply) { strcpy(reply, "not available"); } + virtual void formatRadioStatsReply(char* reply) { strcpy(reply, "not available"); } + virtual void formatPacketStatsReply(char* reply) { strcpy(reply, "not available"); } virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; virtual void clearStats() = 0; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 6289d15..a75eca9 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -54,6 +54,9 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #ifdef ZEPHCORE_LORA #include +#include +#include +#include #endif /* @@ -253,6 +256,12 @@ static size_t write_frame(const uint8_t *src, size_t len) * 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. */ 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. */ + if (zephcore_usb_companion_is_text_session()) { + return len; + } return zephcore_usb_companion_write_frame(src, len); } #endif @@ -271,8 +280,10 @@ static void push_callback(uint8_t code, const uint8_t *data, size_t len) * dropped on a USB-attached client. */ bool transport_up = zephcore_ble_is_connected(); #if ZEPHCORE_USB_STACK + /* A text-CLI session is not a binary companion — don't push V3 frames to it. */ transport_up = transport_up || - (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB); + (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB && + !zephcore_usb_companion_is_text_session()); #endif if (!transport_up) return; @@ -552,6 +563,81 @@ static void save_prefs_to_flash(void) { data_store.savePrefs(companion_mesh_ptr->prefs); } + +/* ========== Companion CLI (USB text sideband only) ========== + * Text CLI lives on USB CDC only: its '<' sync byte cleanly separates binary + * V3 frames from text, whereas BLE NUS frames have no prefix and V3 opcodes + * overlap printable ASCII, so a BLE text sideband can't be disambiguated. */ +#if ZEPHCORE_USB_STACK + +class CompanionCLICallbacks : public CommonCLICallbacks { +public: + void savePrefs() override { + data_store.savePrefs(companion_mesh.prefs); + } + const char* getFirmwareVer() override { return "v1.15.6-zephyr"; } + const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } + const char* getRole() override { return "companion"; } + bool formatFileSystem() override { return data_store.formatFileSystem(); } + + /* Advert / timer controls — mesh-internal; stub for now. */ + void sendSelfAdvertisement(int delay_millis, bool flood) override { + (void)delay_millis; (void)flood; + } + void updateAdvertTimer() override {} + void updateFloodAdvertTimer() override {} + + /* Log control — no log file on 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). */ + void setTxPower(int8_t power_dbm) override { + LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm); + } + + mesh::LocalIdentity& getSelfId() override { return companion_mesh.self_id; } + + void saveIdentity(const mesh::LocalIdentity& new_id) override { + companion_mesh.self_id = new_id; + data_store.saveMainIdentity(new_id); + } + + void clearStats() override { + lora_radio.resetStats(); + companion_mesh.resetStats(); + } + + /* Temp radio params — deferred; stub for now. */ + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, + int timeout_mins) override { + (void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins; + } +}; + +static CompanionCLICallbacks companion_cli_cbs; +static ClientACL companion_acl; /* unused by CommonCLI but required by constructor */ +static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl, + &companion_mesh.prefs, &companion_cli_cbs); + +/* Dispatch a CLI text 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. */ +static void companion_cli_dispatch(const char *line) +{ + char reply[CLI_REPLY_SIZE]; + reply[0] = '\0'; + companion_cli.handleCommand(0, line, reply); + if (reply[0] != '\0') { + zephcore_usb_companion_write_text("\r\n -> ", 7); + zephcore_usb_companion_write_text(reply, strlen(reply)); + } + zephcore_usb_companion_write_text("\r\n", 2); +} + +#endif /* ZEPHCORE_USB_STACK */ + #endif /* GPS enable callback - logs state changes @@ -879,6 +965,8 @@ int main(void) 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 '<'. */ + zephcore_usb_companion_set_cli_line_cb(companion_cli_dispatch); #endif #if IS_ENABLED(CONFIG_BT)