companion CLI

This commit is contained in:
liquidraver
2026-06-04 22:43:01 +02:00
parent d3ebb2f172
commit eda46166b4
5 changed files with 278 additions and 40 deletions
+1
View File
@@ -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
+160 -34
View File
@@ -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,
@@ -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
+7 -5
View File
@@ -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;
+89 -1
View File
@@ -54,6 +54,9 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <mesh/RadioIncludes.h>
#ifdef ZEPHCORE_LORA
#include <app/CompanionMesh.h>
#include <helpers/CommonCLI.h>
#include <helpers/ClientACL.h>
#include <helpers/battery_curve.h>
#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 -> <reply>\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)