diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d05d6562..d60b2961 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -146,6 +146,7 @@ jobs: python3 -B test/test_companion_hardware_serial_gps.py -v python3 -B test/test_companion_contact_lifetime.py -v python3 -B test/test_companion_identity_cli.py -v + python3 -B test/test_companion_packet_stream.py -v python3 -B test/test_companion_uncached_storage.py -v python3 -B test/test_companion_ota_config.py -v python3 -B test/test_ota_identity_policy.py -v diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index ea7668af..f1555a64 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -111,7 +111,7 @@ getters; relaying a command over LoRa keeps its remote restrictions. | ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. | | FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging, USB debug defaulting off, and the complete command surface supported by that role and hardware. Debug verbosity can be enabled only for diagnostics compiled into that image. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths; `get/set usb.debug` separately saves USB verbosity. | | FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet support compiled and the complete command surface supported by that role and hardware. USB debug defaults off; `get/set usb.debug` selects verbose output independently of packet capture. Its persistent USB master also covers output-off operation, avoiding a second FULL ESP-NOW image. | -| nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds output-only interface `02` for ASCII RAW/RX/TX records and optional debug. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present; `get/set usb.debug` changes verbosity without a reboot. Send CLI/control queries to interface `00`, not the log endpoint. | +| nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. `set usb.logging on reboot` adds output-only interface `02` for ASCII RAW/RX/TX records and optional debug. Alternatively, `set usb.logging stream reboot` keeps CLI replies and packet records on interface `00` for unmodified one-port serial bridges; Binary Companion and serial mOTA require logging off on that port. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present; `get/set usb.debug` changes verbosity without a reboot. Send CLI/control queries to interface `00`, not the log endpoint. | | ESP32 single-TTY Full Companion | Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete `<` frame to framed Companion. On 1.17.1.5, run `set powersaving off` first. `set usb.logging on` switches that TTY to an input-capable plaintext RAW/RX/TX logging terminal and makes framed Companion unavailable on USB; `set usb.logging off` stops logging but leaves the TTY in normal ASCII mode. `get/set usb.debug` separately controls saved verbose diagnostics, defaulting off. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. | | `no_external_sensors` | Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. | diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 37242e64..ffd28158 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -724,7 +724,17 @@ that a reboot is required. Use `set usb.logging on reboot` to save it and have the node reboot automatically after the reply. The second interface appears after that reboot. Likewise, `set usb.logging off reboot` removes interface `02`. The optional `reboot` word is accepted only in these exact command forms -and triggers a reboot only when the descriptor actually needs to change. +and triggers a reboot only when the USB logging mode or descriptor needs to change. + +For serial bridges that use one port for both commands and packets, including +unmodified Cisien/meshcoretomqtt, use `set usb.logging stream reboot` instead. +This saved packet-stream mode keeps only interface `00` and emits plaintext +RAW/RX/TX records together with repeater-style ` -> ` CLI replies. Configure +the bridge with the stable `*-if00` path. Binary Companion and serial mOTA are +unavailable on that port while logging is enabled; `set usb.logging off` stops +the stream and leaves the ASCII terminal active. BLE remains available. +`get usb.logging` reports `stream` and the primary port after reboot. To return +to separate logging, use `set usb.logging on reboot`. These are Full Companion **text-terminal** commands. The superficially similar `meshcli ... get usb.logging` command uses the Binary Companion parameter diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index 9bde2961..f03adb53 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -51,6 +51,7 @@ editor does not necessarily forward them. | External FEM TX gain | `set radio.fem.txgain on` | `set radio.fem.txgain off` | Immediately; saved; controllable PA only | | ESP32 USB packet/debug logging | For 1.17.1.5: `set powersaving off`, then `set usb.logging on` | `set usb.logging off` | Immediately; saved; logging owns the USB terminal | | nRF52 separate USB logging port | `set usb.logging on reboot` | `set usb.logging off reboot` | Saves and reboots to add/remove the second USB port | +| nRF52 one-port packet stream | `set usb.logging stream reboot` | `set usb.logging off` | Saved; CLI and packets share the primary port for stock serial bridges | | ESP32 browser settings | `set webui on` | `set webui off` | Saved; starts/stops WebConfig | | ESP32 temporary setup portal | `start webconfig ap` | `stop webconfig` | This session; opens a setup network/QR where available | | ESP32 WiFi firmware uploader | `start ota` or `start ota ap` | `stop ota` | This session; only usable with two application slots | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 53653cb8..fe59738f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2671,7 +2671,7 @@ void MyMesh::begin(bool has_display, bool radio_available, _prefs.rx_powersaving_enabled = constrain(_prefs.rx_powersaving_enabled, 0, 1); _prefs.powersaving_enabled = constrain(_prefs.powersaving_enabled, 0, 1); _prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1); - _prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 1); + _prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 2); _prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0; _prefs.cad_enabled = constrain(_prefs.cad_enabled, 0, 1); if (_prefs.cad_scan_timeout_ms != 0 @@ -2694,6 +2694,7 @@ void MyMesh::begin(bool has_display, bool radio_available, } #if MESH_USB_LOGGING_AVAILABLE const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0; + mesh::configureUsbLoggingPacketStream(_prefs.usb_logging_enabled == 2); mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0); applyUsbLoggingState(usb_logging_enabled); if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) { @@ -3404,9 +3405,13 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } if (strcmp(command, "get usb.logging") == 0) { snprintf(reply, reply_size, "usb.logging %s; port: %s%s", - mesh::isUsbLoggingEnabled() ? "on" : "off", + mesh::isUsbLoggingEnabled() + ? (mesh::isUsbLoggingPacketStream() ? "stream" : "on") : "off", mesh::usbLoggingPortDescription(), - mesh::usbLoggingInterfaceRestartRequired() + (mesh::usbLoggingInterfaceRestartRequired() + || (_prefs.usb_logging_enabled != 0 + && (_prefs.usb_logging_enabled == 2) + != mesh::isUsbLoggingPacketStream())) ? " (reboot required to change USB interfaces)" : ""); return true; } @@ -3416,29 +3421,37 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, while (*value == ' ' || *value == '\t') value++; bool enabled = false; bool reboot_if_needed = false; - if (!mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) { - snprintf(reply, reply_size, "Error: use set usb.logging [reboot]"); + const bool packet_stream = strcmp(value, "stream") == 0 + || strcmp(value, "stream reboot") == 0; + if (packet_stream) { + enabled = true; + reboot_if_needed = value[6] != 0; + } + if (!packet_stream + && !mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) { + snprintf(reply, reply_size, "Error: use set usb.logging [reboot]"); } else { - _prefs.usb_logging_enabled = enabled ? 1 : 0; + _prefs.usb_logging_enabled = enabled ? (packet_stream ? 2 : 1) : 0; applyUsbLoggingState(enabled); if (!savePrefs()) { snprintf(reply, reply_size, "Error: USB logging changed for this boot but save failed"); } else if (!mesh::saveUsbLoggingBootPreference(enabled)) { snprintf(reply, reply_size, "Error: setting saved, but next-boot USB interface state could not be saved"); - } else if (mesh::usbLoggingInterfaceRestartRequired()) { + } else if (mesh::usbLoggingInterfaceRestartRequired() + || (enabled && packet_stream != mesh::isUsbLoggingPacketStream())) { if (reboot_if_needed) { snprintf(reply, reply_size, "OK - USB logging %s (saved); rebooting to change USB interfaces", - enabled ? "on" : "off"); + enabled ? (packet_stream ? "stream" : "on") : "off"); _scheduled_reboot_at = futureMillis(1000); } else { snprintf(reply, reply_size, "OK - USB logging %s (saved); reboot required to change USB interfaces", - enabled ? "on" : "off"); + enabled ? (packet_stream ? "stream" : "on") : "off"); } } else { snprintf(reply, reply_size, "OK - USB logging %s (saved)", - enabled ? "on" : "off"); + enabled ? (packet_stream ? "stream" : "on") : "off"); } } return true; @@ -3937,7 +3950,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } else { _mqtt_enabled = wifi; if (!wifi) stopMQTT(); - _prefs.usb_logging_enabled = usb ? 1 : 0; + _prefs.usb_logging_enabled = usb + ? (_prefs.usb_logging_enabled == 2 ? 2 : 1) : 0; applyUsbLoggingState(usb); if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) { snprintf(reply, reply_size, "Error: logging output changed but USB setting could not be saved"); @@ -8831,16 +8845,18 @@ void MyMesh::handleTerminalCommand(char* command) { if (mesh::handleReaderCommand(command, terminalOutput())) return; #endif + // Match the repeater ASCII reply envelope so unmodified serial packet + // bridges can query this role. Framed/BLE replies retain their payloads. char local_reply[160]; if (handleCompanionWirelessCommand(command, local_reply, sizeof(local_reply), _terminal_mode ? CompanionWirelessSource::Usb : CompanionWirelessSource::Network) || handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply), CompanionBluetoothCommandSource::Terminal)) { - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); return; } if (handleDirectCommand(command, local_reply, sizeof(local_reply))) { - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); return; } #if COMPANION_FEATURE_USB_MOTA_SOURCE @@ -8851,7 +8867,7 @@ void MyMesh::handleTerminalCommand(char* command) { #endif if (!usb_mota_owner_transition && handleLocalControlCommand(command, local_reply, sizeof(local_reply))) { - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); return; } @@ -9090,7 +9106,7 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strncmp(command, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(command + 12, reply); - terminalOutput().printf(" %s\r\n", reply); + terminalOutput().printf(" -> %s\r\n", reply); } else if (strcmp(command, "get radio.rxps.config") == 0) { if (!radio_driver.supportsRxPowerSaving()) { terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); @@ -9155,7 +9171,7 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strcmp(command, "get wifi.powersave") == 0) { char reply[160]; formatWiFiPowerSaving(reply, sizeof(reply)); - terminalOutput().printf(" %s\r\n", reply); + terminalOutput().printf(" -> %s\r\n", reply); #endif } else if (strcmp(command, "get radio.rxgain") == 0) { if (!radio_driver.supportsRxBoostedGainMode()) { @@ -9187,11 +9203,11 @@ void MyMesh::handleTerminalCommand(char* command) { if (strncmp(config, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(config + 12, reply); - terminalOutput().printf(" %s\r\n", reply); + terminalOutput().printf(" -> %s\r\n", reply); } else if (strncmp(config, "radio.rxps ", 11) == 0) { char reply[160]; applyAndSaveRxPowerSaving(config + 11, reply); - terminalOutput().printf(" %s\r\n", reply); + terminalOutput().printf(" -> %s\r\n", reply); #if defined(ESP32) && defined(WIFI_SSID) } else if (strncmp(config, "wifi.powersave", 14) == 0 && (config[14] == 0 || config[14] == ' ' @@ -9200,7 +9216,7 @@ void MyMesh::handleTerminalCommand(char* command) { while (*value == ' ' || *value == '\t') value++; char reply[160]; applyAndSaveWiFiPowerSaving(value, reply, sizeof(reply)); - terminalOutput().printf(" %s\r\n", reply); + terminalOutput().printf(" -> %s\r\n", reply); #endif } else if (strncmp(config, "radio.rxgain", 12) == 0 && (config[12] == 0 || config[12] == ' ' @@ -9248,10 +9264,10 @@ void MyMesh::handleTerminalCommand(char* command) { } } else if (strncmp(config, "tx ", 3) == 0) { handleCommand(command, 0, local_reply); - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); } else if (strncmp(config, "freq ", 5) == 0) { handleCommand(command, 0, local_reply); - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); } else if (strncmp(config, "radio.fem.rxgain", 16) == 0 && (config[16] == 0 || config[16] == ' ' || config[16] == '\t')) { @@ -9290,7 +9306,7 @@ void MyMesh::handleTerminalCommand(char* command) { // changing the established terminal behavior of `set tx`, `set name`, // or the WiFi commands. if (handleCommand(command, 0, local_reply)) { - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); } else { terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config); } @@ -9305,7 +9321,7 @@ void MyMesh::handleTerminalCommand(char* command) { scanInternalExtraFS(terminalOutput()); #endif } else if (strcmp(command, "ver") == 0) { - terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n", + terminalOutput().printf(" -> Companion %s (protocol %u, build %s)\r\n", FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE); } else if (strcmp(command, "help") == 0) { terminalOutput().print( @@ -9363,7 +9379,7 @@ void MyMesh::handleTerminalCommand(char* command) { #if MESH_USB_LOGGING_AVAILABLE terminalOutput().print( " get usb.logging\r\n" - " set usb.logging [reboot]\r\n" + " set usb.logging [reboot]\r\n" " get usb.debug\r\n" " set usb.debug \r\n" #if MESH_USB_CONSOLE_COOPERATIVE @@ -9465,7 +9481,7 @@ void MyMesh::handleTerminalCommand(char* command) { // Fill the same safe shared-command surface for getters (`get name`, // `get radio`, `get tx`, and variant commands). Terminal-only commands // above still win, including the richer `ver` response and reboot flow. - terminalOutput().printf(" %s\r\n", local_reply); + terminalOutput().printf(" -> %s\r\n", local_reply); } else { terminalOutput().printf(" ERROR: unknown command: %s\r\n", command); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index e282fe97..065566b9 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -72,7 +72,7 @@ public: uint8_t powersaving_enabled = 0; uint8_t wifi_enabled = 0; uint8_t powersaving_policy_version = 0; - uint8_t usb_logging_enabled = 0; + uint8_t usb_logging_enabled = 0; // 0=off, 1=separate where supported, 2=primary packet stream char bluetooth_name[mesh::companion::BLUETOOTH_NAME_SIZE] = {}; uint16_t display_rotation_degrees = 0; uint8_t cad_enabled = 0; diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 0e8669cd..8c79b86c 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -675,9 +675,7 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) { millis() + primary_usb_session_settle_millis, std::memory_order_release); primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel); -#if !defined(MESH_DUAL_CDC_LOGGING) - clearUsbLoggingClientActivity(); -#endif + if (!hasDedicatedUsbLoggingPort()) clearUsbLoggingClientActivity(); if (clear_cdc_fifos) { // The line-state callback runs before TinyUSB resets the device. A device // unmount/remount boundary has already reset these class FIFOs. @@ -711,11 +709,8 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data, const size_t available = tud_cdc_n_write_available(instance); const size_t attempt = size < available ? size : available; if (attempt == 0) return 0; -#if defined(MESH_DUAL_CDC_LOGGING) - if (instance == 1) noteUsbLoggingTxAttempt(); -#else - if (instance == 0) noteUsbLoggingTxAttempt(); -#endif + if (instance == (hasDedicatedUsbLoggingPort() ? 1 : 0)) + noteUsbLoggingTxAttempt(); return tud_cdc_n_write(instance, data, attempt); } @@ -1020,7 +1015,8 @@ void serviceDedicatedUsbLoggingFromUsbTask() { usb_task_dedicated_usb_logging_port.drainOne(); (void)tud_cdc_n_write_flush(1); } -#elif defined(NRF52_PLATFORM) +#endif +#if defined(NRF52_PLATFORM) // Single-CDC nRF52 roles need the same protection. In particular, BLE debug // callbacks and packet logging write through usbLoggingPort() without going // through MeshCore's formatted-debug helper. @@ -1069,6 +1065,16 @@ bool saveUsbLoggingBootPreference(bool enabled) { return true; } +static std::atomic usb_logging_packet_stream{false}; + +void configureUsbLoggingPacketStream(bool enabled) { + usb_logging_packet_stream.store(enabled, std::memory_order_release); +} + +bool isUsbLoggingPacketStream() { + return usb_logging_packet_stream.load(std::memory_order_acquire); +} + void setUsbCompanionTxBufferCapacity(size_t capacity) { #if MESH_ESP32_HWCDC_SESSION_GUARD esp32_hwcdc_tx_buffer_capacity.store(capacity, std::memory_order_release); @@ -1126,7 +1132,7 @@ void beginUsbLoggingPort() { } #endif #if defined(MESH_DUAL_CDC_LOGGING) - if (dedicated_usb_logging_port_started + if (isUsbLoggingPacketStream() || dedicated_usb_logging_port_started || !usb_logging_preference_known.load(std::memory_order_relaxed) || !isUsbLoggingEnabled()) { return; @@ -1174,6 +1180,7 @@ void serviceUsbLoggingPort() { serviceEsp32HwcdcTxKick(); #endif #if defined(MESH_DUAL_CDC_LOGGING) + if (isUsbLoggingPacketStream()) return; const bool connected = dedicated_usb_logging_port_started && dedicated_usb_logging_port.dtr(); const bool should_service = connected && isUsbLoggingEnabled(); @@ -1367,12 +1374,14 @@ bool resetUsbCompanionTransport() { Stream& usbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started - && dedicated_usb_logging_port_connected.load(std::memory_order_acquire)) { - return nonblocking_dedicated_usb_logging_port; + if (!isUsbLoggingPacketStream()) { + if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started + && dedicated_usb_logging_port_connected.load(std::memory_order_acquire)) { + return nonblocking_dedicated_usb_logging_port; + } + return null_usb_logging_stream; } - return null_usb_logging_stream; -#else +#endif if (!isUsbLoggingEnabled()) return null_usb_logging_stream; #if MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_logging_port; @@ -1383,7 +1392,6 @@ Stream& usbLoggingPort() { #else return Serial; #endif -#endif } Stream& usbDebugPort() { @@ -1600,7 +1608,7 @@ bool tryCompleteUsbTerminalSessionReset() { bool hasDedicatedUsbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - return true; + return !isUsbLoggingPacketStream(); #else return false; #endif @@ -1616,7 +1624,8 @@ bool isDedicatedUsbLoggingPortConfigured() { bool usbLoggingInterfaceRestartRequired() { #if defined(MESH_DUAL_CDC_LOGGING) - return dedicated_usb_logging_port_configured != isUsbLoggingEnabled(); + return dedicated_usb_logging_port_configured + != (isUsbLoggingEnabled() && hasDedicatedUsbLoggingPort()); #else return false; #endif @@ -1624,10 +1633,10 @@ bool usbLoggingInterfaceRestartRequired() { const char* usbLoggingPortDescription() { #if defined(MESH_DUAL_CDC_LOGGING) - return "dedicated USB CDC 1, interface 02 (Linux: *-if02; tty/COM name is host-assigned)"; -#else - return "primary USB serial port (tty/COM name is host-assigned)"; + if (hasDedicatedUsbLoggingPort()) + return "dedicated USB CDC 1, interface 02 (Linux: *-if02; tty/COM name is host-assigned)"; #endif + return "primary USB serial port (tty/COM name is host-assigned)"; } UsbLoggingObservation observeUsbLoggingTransport() { @@ -1660,7 +1669,9 @@ UsbLoggingObservation observeUsbLoggingTransport() { result.pending = usb_logging_tx_waiting.load(std::memory_order_acquire) || tud_cdc_n_write_available(instance) < CFG_TUD_CDC_TX_BUFSIZE; #if defined(MESH_DUAL_CDC_LOGGING) - result.pending = result.pending || usb_task_dedicated_usb_logging_port.queuedRecordCount() != 0; + result.pending = result.pending || (hasDedicatedUsbLoggingPort() + ? usb_task_dedicated_usb_logging_port.queuedRecordCount() != 0 + : hasPendingUsbTerminalOutput()); #else result.pending = result.pending || hasPendingUsbTerminalOutput(); #endif @@ -1721,16 +1732,19 @@ UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { #elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) if (stage == 1) { #if defined(MESH_DUAL_CDC_LOGGING) - // Only publish a request. FIFO/identity/queue mutation remains in CDC1's - // existing TinyUSB-owner SOF service, never in this application call. - dedicated_usb_logging_watchdog_reset_requested.store(true, std::memory_order_release); - dedicated_usb_logging_port_connected.store(false, std::memory_order_release); - if (tud_cdc_n_connected(1)) tud_sof_cb_enable(true); -#else - endPrimaryUsbHostSession(false); - clearNrf52UsbTxForSession(0); - probeUsbLoggingTransport(); + if (hasDedicatedUsbLoggingPort()) { + // Only publish a request. FIFO/identity/queue mutation remains in + // CDC1's existing TinyUSB-owner SOF service, never in this call. + dedicated_usb_logging_watchdog_reset_requested.store(true, std::memory_order_release); + dedicated_usb_logging_port_connected.store(false, std::memory_order_release); + if (tud_cdc_n_connected(1)) tud_sof_cb_enable(true); + } else #endif + { + endPrimaryUsbHostSession(false); + clearNrf52UsbTxForSession(0); + probeUsbLoggingTransport(); + } } else if (!usb_logging_watchdog_detached) { if (!tud_disconnect()) return UsbLoggingRecoveryResult::Deferred; usb_logging_watchdog_attach_at = millis() + 50; @@ -1762,11 +1776,8 @@ bool isUsbLoggingTransportRecoveryPending() { #if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) extern "C" void meshTinyUsbLoggingTxComplete(uint8_t instance) { -#if defined(MESH_DUAL_CDC_LOGGING) - if (instance == 1) mesh::noteUsbLoggingTxComplete(); -#else - if (instance == 0) mesh::noteUsbLoggingTxComplete(); -#endif + if (instance == (mesh::hasDedicatedUsbLoggingPort() ? 1 : 0)) + mesh::noteUsbLoggingTxComplete(); } #endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index 2cc6c728..f38ac35e 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -90,6 +90,11 @@ inline bool isUsbDebugLoggingEnabled() { // descriptor mirror; nRF52 selects its optional second interface after loading // the normal role preferences. bool saveUsbLoggingBootPreference(bool enabled); +// Select the saved Companion packet-stream mode before preferences enable +// logging and before the optional second CDC is configured. This keeps CLI +// replies and packet records on one port for stock serial packet bridges. +void configureUsbLoggingPacketStream(bool enabled); +bool isUsbLoggingPacketStream(); // Record the HWCDC TX ring size actually allocated during early setup. A zero // value keeps reset cleanup quarantined and eligible for a minimum-size retry diff --git a/test/test_companion_identity_cli.py b/test/test_companion_identity_cli.py index 2a9f1b87..a7c01bba 100644 --- a/test/test_companion_identity_cli.py +++ b/test/test_companion_identity_cli.py @@ -179,7 +179,7 @@ class CompanionIdentityCliTests(unittest.TestCase): source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() terminal = method(source, 'void MyMesh::handleTerminalCommand(') self.assertIn('handleLocalControlCommand(command, local_reply, sizeof(local_reply))', terminal) - self.assertIn('terminalOutput().printf(" %s\\r\\n", local_reply);', terminal) + self.assertIn('terminalOutput().printf(" -> %s\\r\\n", local_reply);', terminal) self.assertIn('terminalOutput().print(" get public.key\\r\\n");', terminal) shared = method(source, 'bool MyMesh::handleCommand(') self.assertIn('if (handleLocalControlCommand(command, reply, reply_capacity)) return true;', shared) diff --git a/test/test_companion_packet_stream.py b/test/test_companion_packet_stream.py new file mode 100644 index 00000000..35332d13 --- /dev/null +++ b/test/test_companion_packet_stream.py @@ -0,0 +1,259 @@ +"""Execute saved Companion stream-mode selection and its production USB routes. + +Framework endpoints are native boundaries. Preference I/O, the CLI branches, +boot selection, and USB routing/descriptor functions are actual source bodies. +The complete primary-CDC transport fixture separately exercises real queues. +""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_companion_preferences_transaction import HARNESS as STORE_HARNESS +import test_companion_usb_logging as logging_tests +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +MODE_HARNESS = r''' +#include +#include +struct Endpoint : Stream { + unsigned begins=0; std::string output; + int available() override{return 0;} int read() override{return -1;} + int peek() override{return -1;} + size_t write(uint8_t b) override{output+=char(b);return 1;} + size_t write(const uint8_t* p,size_t n) override{output.append((const char*)p,n);return n;} + void begin(unsigned){++begins;} +}; +struct UsbDevice {unsigned detaches=0,attaches=0; + void detach(){++detaches;} void attach(){++attaches;}} TinyUSBDevice; +template T constrain(T value,int low,int high){return T(valuehigh?high:value);} +uint32_t futureMillis(uint32_t value){return value+10;} +namespace mesh { +static std::atomic usb_logging_preference_known{false}; +bool logging=false; +bool dedicated_usb_logging_port_started=false,dedicated_usb_logging_port_configured=false; +std::atomic dedicated_usb_logging_port_connected{false}; +Endpoint dedicated_usb_logging_port,nonblocking_dedicated_usb_logging_port; +Endpoint nonblocking_primary_usb_logging_port,null_usb_logging_stream; +bool isUsbLoggingEnabled(){return logging;} +bool isUsbDebugLoggingEnabled(){return false;} +void setPlatformDebugOutputEnabled(bool){} +void setUsbDebugEnabled(bool){} +void setUsbLoggingEnabled(bool enabled){logging=enabled;usb_logging_preference_known=true;} +bool saveUsbLoggingBootPreference(bool){return true;} +@USB_FUNCTIONS@ +void freshBoot(){ + dedicated_usb_logging_port_started=false;dedicated_usb_logging_port_configured=false; + dedicated_usb_logging_port_connected=false;dedicated_usb_logging_port.begins=0; + TinyUSBDevice.detaches=0;TinyUSBDevice.attaches=0; + usb_logging_preference_known=false;logging=false;configureUsbLoggingPacketStream(false); +} +} +struct {double node_lat=47.1,node_lon=-122.2;} sensors; +bool usb_logging_reply_hold=false,usb_logging_reply_staged=false,usb_logging_reply_state=false; +bool usb_logging_reply_pending=false,usb_protocol_initialized=true; +unsigned ownership_checks=0; +void serviceUsbLoggingOwnership(bool){++ownership_checks;} +struct MyMesh { + CompanionNodePrefs _prefs;DataStore store;DataStore* _store=&store; + uint32_t _scheduled_reboot_at=0; + @SAVE_PREFS@ + @APPLY@ + bool command(const char* command,char* reply,size_t reply_size){ + @COMMANDS@ + return false; + } + void boot(){ + @NORMALIZE@ + @BOOT@ + mesh::beginUsbLoggingPort(); + } +}; +static void bootMode(MyMesh& node,uint8_t expected){ + DataStore reboot;reboot.fs=node.store.fs; + CompanionNodePrefs saved;double lat=0,lon=0; + assert(reboot.loadPrefs(saved,lat,lon));assert(saved.usb_logging_enabled==expected); + node._prefs=saved;mesh::freshBoot();node.boot(); + assert(node._prefs.usb_logging_enabled==expected); + assert(mesh::isUsbLoggingPacketStream()==(expected==2)); + assert(mesh::isUsbLoggingEnabled()==(expected!=0)); + assert(!mesh::usbLoggingInterfaceRestartRequired()); +#if defined(MESH_DUAL_CDC_LOGGING) + assert(mesh::dedicated_usb_logging_port_configured==(expected==1)); + assert(mesh::dedicated_usb_logging_port.begins==(expected==1)); + assert(TinyUSBDevice.detaches==(expected==1)&&TinyUSBDevice.attaches==(expected==1)); +#else + assert(!mesh::dedicated_usb_logging_port_configured); +#endif + if(expected==2){ + assert(!mesh::hasDedicatedUsbLoggingPort()); + assert(&mesh::usbLoggingPort()==&mesh::nonblocking_primary_usb_logging_port); + assert(strstr(mesh::usbLoggingPortDescription(),"primary")); + }else if(expected==0){assert(&mesh::usbLoggingPort()==&mesh::null_usb_logging_stream);} + else { +#if defined(MESH_DUAL_CDC_LOGGING) + assert(mesh::hasDedicatedUsbLoggingPort()); + assert(&mesh::usbLoggingPort()==&mesh::null_usb_logging_stream); + mesh::dedicated_usb_logging_port_connected=true; + assert(&mesh::usbLoggingPort()==&mesh::nonblocking_dedicated_usb_logging_port); +#else + assert(&mesh::usbLoggingPort()==&mesh::nonblocking_primary_usb_logging_port); +#endif + } +} +int main(){ + MyMesh node;strcpy(node._prefs.node_name,"stable owned identity"); + node._prefs.freq=915.125f;node._prefs.ble_pin=246810;node._prefs.bluetooth_enabled=1; + char reply[160]={}; + for(uint8_t mode : {0,1,2}){ + node._prefs.usb_logging_enabled=mode;assert(node.savePrefs()); + assert(node.store.fs.files["/new_prefs"][158]==mode); + bootMode(node,mode); + assert(!strcmp(node._prefs.node_name,"stable owned identity")); + assert(node._prefs.freq==915.125f&&node._prefs.ble_pin==246810); + } + node._prefs.usb_logging_enabled=1;assert(node.savePrefs());bootMode(node,1); + const auto original=node.store.fs.files["/new_prefs"]; + const unsigned attached=TinyUSBDevice.attaches,detached=TinyUSBDevice.detaches; + for(const char* invalid : {"set usb.logging stream extra","set usb.logging STREAM", + "set usb.logging stream ","set usb.logging stream reboot ","set usb.loggingstream"}){ + const auto before=node.store.fs.files["/new_prefs"]; + const bool handled=node.command(invalid,reply,sizeof(reply)); + if(handled)assert(strstr(reply,"Error:")); + assert(node.store.fs.files["/new_prefs"]==before&&node._prefs.usb_logging_enabled==1); + } + assert(node.command("set usb.logging stream",reply,sizeof(reply))); + assert(node._prefs.usb_logging_enabled==2&&node.store.fs.files["/new_prefs"][158]==2); + assert(strstr(reply,"reboot required"));assert(node._scheduled_reboot_at==0); + assert(!mesh::isUsbLoggingPacketStream()); // Saved choice cannot flip a live descriptor/route. + assert(TinyUSBDevice.attaches==attached&&TinyUSBDevice.detaches==detached); + const auto stream=node.store.fs.files["/new_prefs"]; + for(size_t i=0;i usb_logging_packet_stream') + end = usb.index('\nvoid setUsbCompanionTxBufferCapacity', start) + functions = usb[start:end] + '\n' + '\n'.join(method(usb, sig) for sig in ( + 'bool hasDedicatedUsbLoggingPort()', 'bool isDedicatedUsbLoggingPortConfigured()', + 'bool usbLoggingInterfaceRestartRequired()', 'const char* usbLoggingPortDescription()', + 'void beginUsbLoggingPort()', 'Stream& usbLoggingPort()')) + start = mesh.index(' if (strcmp(command, "get usb.logging") == 0)') + end = mesh.index('\n#endif', start) + boot_start = mesh.index(' const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;') + boot_end = mesh.index('\n#endif', boot_start) + normalize = next(line for line in mesh.splitlines() + if '_prefs.usb_logging_enabled = constrain(' in line) + code = MODE_HARNESS.replace('@USB_FUNCTIONS@', functions) + code = code.replace('@COMMANDS@', mesh[start:end]) + code = code.replace('@SAVE_PREFS@', method(header, ' bool savePrefs()')) + code = code.replace('@APPLY@', method(main, 'void MyMesh::applyUsbLoggingState(') + .replace('void MyMesh::applyUsbLoggingState(', 'void applyUsbLoggingState(')) + code = code.replace('@BOOT@', mesh[boot_start:boot_end]).replace('@NORMALIZE@', normalize) + helper = logging_tests.CompanionUsbLoggingTests() + with tempfile.TemporaryDirectory(prefix='companion-packet-stream-') as directory: + work = Path(directory) + helper.prepare(work) + for dual in (False, True): + with self.subTest(dual=dual): + helper.compile_and_run(work, prefix + code, + ['NRF52_PLATFORM=1', 'MESH_USB_LOGGING_AVAILABLE=1'] + + (['MESH_DUAL_CDC_LOGGING=1'] if dual else []), + ('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', + 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')) + + def test_actual_full_dual_compiled_primary_transport(self): + fixture = ROOT / 'test/fixtures/nrf52_usb_console' + with tempfile.TemporaryDirectory(prefix='companion-stream-transport-') as directory: + work = Path(directory) + # The full implementation uses existing native endpoint boundaries; + # only add the optional second-CDC class needed by this profile. + arduino = (fixture / 'mocks/Arduino.h').read_text().replace( + '"../../../mocks/Arduino.h"', '"' + str(ROOT / 'test/mocks/Arduino.h') + '"') + (work / 'Arduino.h').write_text(arduino) + tiny = (fixture / 'mocks/Adafruit_TinyUSB.h').read_text().replace( + '#define CFG_TUD_CDC 1', '#define CFG_TUD_CDC 2').replace( + '#define CFG_TUD_CDC_TX_BUFSIZE 64', '#define CFG_TUD_CDC_TX_BUFSIZE 128').replace( + 'uint32_t tud_cdc_n_write_flush(uint8_t);', + 'extern "C" uint32_t tud_cdc_n_write_flush(uint8_t);') + tiny += ''' +void tud_sof_cb_enable(bool); +uint32_t tud_cdc_n_read(uint8_t,void*,uint32_t); +class Adafruit_USBD_CDC : public Stream { + public: + virtual uint16_t getInterfaceDescriptor(uint8_t,uint8_t*,uint16_t){return 0;} + void setStringDescriptor(const char*){} void begin(unsigned){} bool dtr(){return false;} + int available() override{return 0;} int read() override{return -1;} int peek() override{return -1;} + int availableForWrite() override{return 128;} + size_t write(uint8_t) override{assert(false);return 0;} + size_t write(const uint8_t*,size_t) override{assert(false);return 0;} +}; +''' + (work / 'Adafruit_TinyUSB.h').write_text(tiny) + source = (fixture / 'test_console.cpp').read_text() + source = source.replace('int main() {', + 'void tud_sof_cb_enable(bool){}\n' + 'uint32_t tud_cdc_n_read(uint8_t n,void*,uint32_t){assert(n==1);return 0;}\n' + 'int main() {', 1) + # Run the unchanged comprehensive queue/session fixture on the + # dual-capable build, selecting primary stream before enable. + source = source.replace(' mesh::setUsbLoggingEnabled(true);', + ' mesh::configureUsbLoggingPacketStream(true);\n' + ' assert(!mesh::hasDedicatedUsbLoggingPort());\n' + ' mesh::setUsbLoggingEnabled(true);', 1) + (work / 'test.cpp').write_text(source) + binary = work / 'test' + built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + '-DARDUINO', '-DNRF52_PLATFORM', '-DUSE_TINYUSB', '-DENABLE_USB_INTERFACE', + '-DMESH_DEBUG=1', '-DMESH_DUAL_CDC_LOGGING=1', + '-DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1', + '-I' + str(work), '-I' + str(fixture / 'mocks'), '-I' + str(ROOT / 'src'), + str(work / 'test.cpp'), *[str(ROOT / 'src/helpers' / name) for name in ( + 'UsbLogging.cpp', 'UsbLoggingClientActivity.cpp', 'UsbLoggingLineStateOverride.cpp')], + '-o', str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(ran.returncode, 0, ran.stderr) + self.assertIn('native nRF52 console transport passed', ran.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_usb_logging_reply.py b/test/test_companion_usb_logging_reply.py index ae5a203c..85960761 100644 --- a/test/test_companion_usb_logging_reply.py +++ b/test/test_companion_usb_logging_reply.py @@ -83,6 +83,7 @@ void discardUsbTerminalOutput() {} Stream& usbCompanionPort() { return stream; } bool saveUsbLoggingBootPreference(bool) { return true; } bool usbLoggingInterfaceRestartRequired() { return false; } +bool isUsbLoggingPacketStream() { return false; } } namespace CompanionMqttSetupPortal { static bool saveEnabled(bool) { return true; }