From 60773ceb8d00df5efb69171c38376abcb26ad28a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 31 Oct 2026 21:04:24 -0700 Subject: [PATCH] Add WM1110 Full Companion over BLE and CH340 UART --- build_legacy.sh | 13 +- docs/_javascript/firmware_picker.js | 9 +- docs/full_companion_features.md | 17 ++ examples/companion_radio/main.cpp | 28 +++- scripts/consolidated_profiles.py | 48 +++++- scripts/generate_picker_controls.py | 17 +- src/helpers/PrimarySerialBackend.h | 19 +++ src/helpers/UsbLogging.cpp | 154 +++++++++++++++--- src/helpers/UsbLogging.h | 8 +- src/helpers/UsbLoggingLineStateOverride.cpp | 6 +- src/helpers/UsbMode.h | 3 + .../mocks/Adafruit_TinyUSB.h | 1 + .../nrf52_uart_console/mocks/Arduino.h | 42 +++++ .../nrf52_uart_console/mocks/class/cdc/cdc.h | 1 + .../nrf52_uart_console/test_console.cpp | 96 +++++++++++ test/test_firmware_picker.js | 25 +++ test/test_nrf52_uart_console.py | 37 +++++ test/test_picker_controls_generator.py | 30 ++++ test/test_wm1110_consolidation.py | 117 +++++++++++++ test/test_wm1110_full_profile.py | 78 +++++++++ variants/wio_wm1110/platformio.ini | 28 ++++ 21 files changed, 738 insertions(+), 39 deletions(-) create mode 100644 src/helpers/PrimarySerialBackend.h create mode 100644 test/fixtures/nrf52_uart_console/mocks/Adafruit_TinyUSB.h create mode 100644 test/fixtures/nrf52_uart_console/mocks/Arduino.h create mode 100644 test/fixtures/nrf52_uart_console/mocks/class/cdc/cdc.h create mode 100644 test/fixtures/nrf52_uart_console/test_console.cpp create mode 100644 test/test_nrf52_uart_console.py create mode 100644 test/test_wm1110_consolidation.py create mode 100644 test/test_wm1110_full_profile.py diff --git a/build_legacy.sh b/build_legacy.sh index 60ba1c647..7ec5aaeac 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3006,6 +3006,12 @@ is_nrf52_companion_radio_full_target() { && is_companion_radio_full_target "$1" } +has_nrf52_dedicated_usb_logging() { + is_nrf52_companion_radio_full_target "$1" \ + && [ "$1" != "t1000e_companion_radio_full" ] \ + && ! pio_env_option_contains "$(get_pio_build_env "$1")" build_flags "MESH_PRIMARY_UART_PORT" +} + # The RAK15002 STORE path is deliberately limited to the canonical RAK4631 # receiver identities and the RAK4631 Full Companion. Exact NOR targets, # deployed bridge identities, ordinary non-OTA roles, and the older strict @@ -3724,8 +3730,7 @@ declare_build_capability_contract() { record_build_expectation "companion.usb_mota_source" "ota folder on" record_build_expectation "companion.mota_sender" \ "_ZN4mesh3ota16SerialMotaSource4read" - if is_nrf52_companion_radio_full_target "$env_name" \ - && [ "$env_name" != "t1000e_companion_radio_full" ]; then + if has_nrf52_dedicated_usb_logging "$env_name"; then record_build_expectation "companion.dedicated_usb_logging" \ "get usb.logging" fi @@ -4474,12 +4479,14 @@ apply_companion_radio_full_profile() { # Lossless name compression retains every seeder target and its wire ID # while recovering flash headroom; reuse the existing bounded OTA decoder. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_TARGET_NAME_FRONT_CODED=1" - if [ "$env_name" = "t1000e_companion_radio_full" ]; then + if ! has_nrf52_dedicated_usb_logging "$env_name"; then # The T1000-E's primary CDC transport is the only USB interface. Its # `usb.logging on` selects an input-capable ASCII logging terminal on # CDC 0. `usb.logging off` restores automatic framed-Companion detection. # Do not instantiate or re-enumerate a second CDC interface on this # board: its native USB/SoftDevice handoff is sensitive to that change. + # UART-backed nRF52 targets such as WM1110 likewise have only their + # CH340 primary stream; never invent a second USB logging endpoint. : else export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1" diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 272b516a8..05a1d777f 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -601,6 +601,9 @@ if (!profile || profile.role !== "companion" || profile.mode !== "full") { return false; } + // WM1110's computer port is CH340C -> Serial1. Its DFU ZIP does not + // imply native USB or a separate logging CDC, even without metadata. + if (/^wio_wm1110_companion_radio_full$/i.test(profile.target || "")) return false; const installKinds = Array.isArray(profile.installKinds) ? profile.installKinds : []; @@ -1152,7 +1155,11 @@ profile.variant = "mqtt-observer"; } } - if (typeof controls.dedicatedUsbLogging === "boolean") { + if (/^wio_wm1110_companion_radio_full$/i.test(profile.target || "")) { + // Keep old/foreign controls from overriding this exact wired backend. + // Qualified current controls also publish an explicit source-bound false. + profile.dedicatedUsbLogging = false; + } else if (typeof controls.dedicatedUsbLogging === "boolean") { profile.dedicatedUsbLogging = controls.dedicatedUsbLogging; } const sensorSource = controls.sensorProfileSource; diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index c60a0bc93..8f21dd73c 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -18,6 +18,23 @@ New **Heltec T114** Full images automatically detect the optional screen and also run without it. Choose **Heltec T114** in the firmware picker for either board configuration; see the [T114 update guide](heltec_t114.md). +**Wio WM1110** also has a combined `wio_wm1110_companion_radio_full` profile. +It keeps Bluetooth and adds the dev kit's CH340C USB-to-UART connection at +115200 baud. This is `Serial1`, not native nRF52 USB CDC: it does not create +a second USB logging port, require USB DTR, or infer a disconnect when a CDC +session changes. `get usb.mode` reports `uart`. The wired interface starts +in the text terminal and accepts the same framed Companion handoff and +explicit terminal commands described below. Logging is off by default; +when enabled, it owns the wired text stream rather than mixing log text into +binary Companion replies. BLE remains an independent link. + +WM1110 Full retains 350 contacts, 40 channels and 256 normal queued frames, +and the shared mOTA source workspace described for nRF52 Full below. The old +BLE-only target remains available for exact historical builds. Adding this +wired Companion interface does not qualify automatic USB bootloader entry or +change the board's firmware programming requirements. The new UART backend +has host-side regression tests; live WM1110 hardware validation is still needed. + The Wireless Paper Full memory-sharing update keeps 350 contacts and 256 queued messages normally. During an mOTA session, 128 message slots are available while the other half holds its workspace. If more than 128 unread diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 01c6545b7..ff4e8bd77 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -841,7 +841,10 @@ static void drainUsbTerminalOutputBeforeProtocolSwitch() { } static bool hasObservableActiveUsbTerminalClient() { -#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \ +#if MESH_PRIMARY_SERIAL_IS_UART + // A USB-UART cable cannot reveal whether a terminal process is open. + return usb_terminal_line_len != 0 || usb_terminal_discard_line; +#elif defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \ && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT // HWCDC exposes only whether USB is plugged in, not whether a host process // has opened the terminal. Treat actual buffered activity as ownership; an @@ -3336,6 +3339,7 @@ static bool hasCompanionNonBluetoothClient() { if (the_mesh.isAnyNetworkTerminalMode()) return true; #endif #if defined(ENABLE_USB_INTERFACE) \ + && !MESH_PRIMARY_SERIAL_IS_UART \ && (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ || (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ && (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0))) @@ -3550,6 +3554,7 @@ public: if (the_mesh.isAnyNetworkTerminalMode()) mask |= mesh::wireless::WiFi; #endif #if defined(ENABLE_USB_INTERFACE) \ + && !MESH_PRIMARY_SERIAL_IS_UART \ && (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ || (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ && (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0))) @@ -4166,6 +4171,17 @@ void setup() { #endif // add usb interface +#if defined(WIO_WM1110) && defined(COMPANION_RADIO_FULL) && COMPANION_RADIO_FULL \ + && defined(ENABLE_USB_INTERFACE) && defined(BLE_PIN_CODE) \ + && defined(MESH_PRIMARY_UART_PORT) && MESH_PRIMARY_SERIAL_IS_UART \ + && MAX_CONTACTS >= 350 && MAX_GROUP_CHANNELS >= 40 && OFFLINE_QUEUE_SIZE >= 256 + // A linked proof of the combined setup, not a claim of physical bench + // qualification. The primary wired stream is explicitly the UART bridge. + static const char wm1110_combined_capability[] = + "meshcore.capability.companion.wm1110.combined.v1"; + const volatile char* wm1110_combined_marker = wm1110_combined_capability; + (void)*wm1110_combined_marker; +#endif #if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) #if defined(PICOW_COMBINED_COMPANION) && PICOW_COMBINED_COMPANION == 1 \ && defined(ENABLE_USB_INTERFACE) && defined(PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH) @@ -4235,6 +4251,16 @@ void setup() { USB_HOST_LOSS_GRACE_MS, the_mesh.hasFiniteDelayedReplyForRoute(&usb_serial_interface)); }); +#elif MESH_PRIMARY_SERIAL_IS_UART + // No native DTR/host-presence signal traverses a CH340-style UART bridge. + // Only a completed command frame can grant a bounded Binary reply route; + // an idle cable or an ASCII byte must not hold up Bluetooth dispatch. + usb_serial_interface.setConnectedCheck([]() { + return mesh::isUsbCompanionClientConnected() + && usb_serial_interface.hasReceivedFrame() + && uint32_t(millis() - usb_serial_interface.getLastFrameMillis()) + < USB_CLIENT_IDLE_TIMEOUT; + }); #elif defined(NRF52_PLATFORM) usb_serial_interface.setConnectedCheck([]() { return mesh::isUsbCompanionClientConnected(); diff --git a/scripts/consolidated_profiles.py b/scripts/consolidated_profiles.py index 13596ce59..d141d2cf1 100644 --- a/scripts/consolidated_profiles.py +++ b/scripts/consolidated_profiles.py @@ -51,6 +51,13 @@ SPECS = ( platform="RP2040_PLATFORM", board="rpipicow", target=r"PicoW_companion_radio", defines={"PICOW_COMBINED_COMPANION": "1", "ENABLE_USB_INTERFACE": "1", "ENABLE_WIFI_INTERFACE": "1", "PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH": "1"}), + dict(key="wm1110CombinedCompanion", capability="companion.wm1110.combined", + marker="meshcore.capability.companion.wm1110.combined.v1", + platform="NRF52_PLATFORM", board="seeed-xiao-afruitnrf52-nrf52840", + target=r"wio_wm1110_companion_radio_full", + defines={"WIO_WM1110": "1", "COMPANION_RADIO_FULL": "1", + "ENABLE_USB_INTERFACE": "1", "MESH_PRIMARY_UART_PORT": "Serial1", + "BLE_PIN_CODE": "123456"}), dict(key="oledExpansionCompatible", capability="hardware.heltec_v4.oled_kit", marker="meshcore.capability.hardware.heltec_v4.oled_kit.v1", platform="ESP32_PLATFORM", board="heltec_v4", @@ -94,6 +101,8 @@ RADIO_CAPACITY_MINIMUMS = { "meshadventurer": dict(MAX_CONTACTS=160, MAX_GROUP_CHANNELS=40, OFFLINE_QUEUE_SIZE=128), "muzi_base": dict(MAX_CONTACTS=350, MAX_GROUP_CHANNELS=40, OFFLINE_QUEUE_SIZE=256), } +WM1110_CAPACITY_MINIMUMS = dict(MAX_CONTACTS=350, MAX_GROUP_CHANNELS=40, + OFFLINE_QUEUE_SIZE=256) def integer_define(value): @@ -115,7 +124,11 @@ def specs_for_config(target, env): return [spec for spec in SPECS if env.get("board") == spec["board"] and re.fullmatch(spec["target"], target, re.I) and all(defines.get(key) == value for key, value in spec["defines"].items()) - and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines))] + and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines)) + and (spec["key"] != "wm1110CombinedCompanion" or all( + integer_define(defines.get(key)) is not None + and integer_define(defines[key]) >= value + for key, value in WM1110_CAPACITY_MINIMUMS.items()))] def proven(manifest, spec): @@ -196,6 +209,8 @@ def radio_replacements(target): def replacements(target): """Known functionality successors, never aliases for deployed update IDs.""" + if target.lower() == "wio_wm1110_companion_radio_ble": + return [("wio_wm1110_companion_radio_full", "wm1110CombinedCompanion")] radio = radio_replacements(target) if radio: return radio @@ -261,6 +276,13 @@ def configuration_replacement(target, envs): if field in old: before, after = integer_define(old[field]), integer_define(new.get(field)) qualified = qualified and before is not None and after is not None and after >= before + if key == "wm1110CombinedCompanion": + old = resolved_defines(source_env.get("build_flags", [])) + new = resolved_defines(env.get("build_flags", [])) + qualified = qualified and old.get("WIO_WM1110") == "1" and "BLE_PIN_CODE" in old + for field in WM1110_CAPACITY_MINIMUMS: + before, after = integer_define(old.get(field)), integer_define(new.get(field)) + qualified = qualified and before is not None and after is not None and after >= before if qualified: return candidate return None @@ -268,10 +290,13 @@ def configuration_replacement(target, envs): def omit_redundant_records(records): """Remove resumed alternatives only with an actual qualified successor.""" + def same_sensors(left, right): + return (sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) + == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + def same_policy(left, right): return (left.get("build_profile") == right.get("build_profile") - and sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) - == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + and same_sensors(left, right)) def qualified(manifest, key): if key == "muziRadioAccessories": @@ -313,6 +338,19 @@ def omit_redundant_records(records): and required <= set(right.get("capabilities", [])) and required <= linked) return True + def wm1110_successor(left, right): + source = left.get("source_commit") + required = {"companion.usb", "companion.bluetooth"} + linked = {row.get("capability") for row in right.get("verification", []) + if row.get("present") is True and row.get("source") == "linked image" + and row.get("evidence")} + return (left.get("target", "").lower() == "wio_wm1110_companion_radio_ble" + and right.get("target", "").lower() == "wio_wm1110_companion_radio_full" + and isinstance(source, str) and re.fullmatch(r"[0-9a-f]{40}", source) + and right.get("source_commit") == source + and right.get("build_profile") == "full" and same_sensors(left, right) + and required <= set(right.get("capabilities", [])) and required <= linked) + result = [] for record in records: manifest = record["manifest"] @@ -320,8 +358,10 @@ def omit_redundant_records(records): if not any(candidate["manifest"]["target"].lower() == target.lower() and (key not in ("autoLoRaRadio", "muziRadioAccessories") or radio_successor(manifest, candidate["manifest"])) + and (key != "wm1110CombinedCompanion" + or wm1110_successor(manifest, candidate["manifest"])) and (same_policy(manifest, candidate["manifest"]) - or key in ("muziAccessories", "techoLiteDisplay") + or key in ("muziAccessories", "techoLiteDisplay", "wm1110CombinedCompanion") and candidate["manifest"].get("build_profile") == "full" and manifest["target"].lower().endswith(("_usb", "_ble"))) and qualified(candidate["manifest"], key) diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index e8c961ce4..3b1e4fe3a 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -14,6 +14,7 @@ from package_cascade_release import embedded_bootloader_variant, nrf52_sensor_pr from station_g3_profiles import runtime_metadata as station_g3_metadata from optional_display_profiles import runtime_metadata as display_metadata from consolidated_profiles import runtime_metadata as consolidation_metadata +from consolidated_profiles import SPECS as CONSOLIDATION_SPECS, proven as consolidation_proven ROOT = Path(__file__).resolve().parents[1] @@ -112,11 +113,23 @@ def runtime_metadata(manifest, mqtt, release_source=None): result['loggingModes'] = ['none', 'usb', 'wifi', 'both'] if mqtt else ['none', 'usb'] result['loggingControl'] = 'logging.output' if mqtt else 'usb.logging' result['loggingSource'] = manifest.get('source_commit', release_source) - if 'companion.dedicated_usb_logging' in verified: - result['dedicatedUsbLogging'] = True linked = {check['capability'] for check in manifest.get('verification', []) if check.get('present') is True and check.get('source') == 'linked image' and check.get('evidence')} + if 'companion.wm1110.combined' in capabilities: + spec = next(spec for spec in CONSOLIDATION_SPECS if spec['key'] == 'wm1110CombinedCompanion') + if (not consolidation_proven(manifest, spec) + or not {'companion.usb', 'companion.bluetooth'} <= capabilities & linked + or 'companion.dedicated_usb_logging' in capabilities | verified): + raise ValueError('WM1110 UART logging requires the exact qualified combined application') + source = manifest.get('source_commit', release_source) + if (not isinstance(source, str) or not re.fullmatch(r'[0-9a-f]{40}', source) + or release_source is not None and source != release_source): + raise ValueError('WM1110 UART logging requires the exact release source') + # Its computer-facing CH340C is a UART bridge, not a second nRF CDC. + result.update(dedicatedUsbLogging=False, dedicatedUsbLoggingSource=source) + elif 'companion.dedicated_usb_logging' in verified: + result['dedicatedUsbLogging'] = True merged_runtime = any(check.get('capability') == 'bridge.rs232.merged' and check.get('present') is True and check.get('source') == 'packaged application' diff --git a/src/helpers/PrimarySerialBackend.h b/src/helpers/PrimarySerialBackend.h new file mode 100644 index 000000000..555be955f --- /dev/null +++ b/src/helpers/PrimarySerialBackend.h @@ -0,0 +1,19 @@ +#pragma once + +// Some nRF52 boards expose a USB-to-UART bridge instead of native USB CDC. +// The explicit build flag names that hardware stream in every translation +// unit; a board-local `#define Serial Serial1` cannot select shared helpers. +#if defined(MESH_PRIMARY_UART_PORT) + #define MESH_PRIMARY_SERIAL_IS_UART 1 +#else + #define MESH_PRIMARY_SERIAL_IS_UART 0 +#endif + +#if defined(ARDUINO) && MESH_PRIMARY_SERIAL_IS_UART +#include +namespace mesh { +inline Stream& primarySerialPort() { + return MESH_PRIMARY_UART_PORT; +} +} // namespace mesh +#endif diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 6d7e15c90..6297ea3da 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -39,7 +39,7 @@ // Build-local Arduino hook queries its actual CDC descriptor's IN endpoint. extern "C" bool meshEsp32TinyUsbTxPending(); #endif -#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART #include #endif #if defined(NRF52_PLATFORM) @@ -49,7 +49,7 @@ #include "UsbAsciiBinarySwitch.h" #endif -#if defined(NRF52_PLATFORM) && \ +#if defined(NRF52_PLATFORM) && !MESH_PRIMARY_SERIAL_IS_UART && \ (defined(USE_TINYUSB) || defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL)) #define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1 #include @@ -65,6 +65,9 @@ #endif #if defined(MESH_DUAL_CDC_LOGGING) + #if MESH_PRIMARY_SERIAL_IS_UART + #error "A primary UART bridge cannot use native dual-CDC logging" + #endif #if !defined(COMPANION_FEATURE_DEDICATED_USB_LOGGING) || \ !COMPANION_FEATURE_DEDICATED_USB_LOGGING #error "MESH_DUAL_CDC_LOGGING requires its dedicated Companion capability" @@ -82,7 +85,7 @@ namespace mesh { -#if defined(ENABLE_USB_INTERFACE) \ +#if defined(ENABLE_USB_INTERFACE) || MESH_PRIMARY_SERIAL_IS_UART \ || (defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE)) // A USB Companion owns its primary stream for framed traffic until saved // preferences are loaded. Starting disabled prevents early boot diagnostics @@ -114,7 +117,7 @@ static void endUsbTextWrite() { usb_text_writers.fetch_sub(1); } static std::atomic usb_logging_tx_progress{0}; static std::atomic usb_logging_tx_waiting{false}; #if MESH_ESP32_TINYUSB_NONBLOCKING \ - || (defined(NRF52_PLATFORM) && defined(USE_TINYUSB)) + || (defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART) static bool usb_logging_watchdog_detached = false; static uint32_t usb_logging_watchdog_attach_at = 0; #endif @@ -140,6 +143,61 @@ class NullUsbLoggingStream : public Stream { static NullUsbLoggingStream null_usb_logging_stream; +#if MESH_PRIMARY_SERIAL_IS_UART +// UART bridges expose no DTR or USB reset event to the MCU. Do not consult an +// unrelated CDC peripheral. Actual input establishes only a bounded activity +// lease; Binary Companion additionally requires a complete framed command. +static constexpr uint32_t primary_uart_client_idle_ms = 10 * 60 * 1000UL; +static uint32_t primary_uart_last_rx_at = 0; +static bool primary_uart_has_rx = false; +static std::atomic primary_uart_terminal_discard_pending{false}; + +static void notePrimaryUartInput() { + primary_uart_has_rx = true; + primary_uart_last_rx_at = millis(); +} + +static size_t writePrimaryUartOnce(void*, const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) return 0; + Stream& port = primarySerialPort(); + const int capacity = port.availableForWrite(); + if (capacity <= 0) return 0; + const size_t attempt = size < static_cast(capacity) + ? size : static_cast(capacity); + noteUsbLoggingTxAttempt(); + const size_t written = port.write(data, attempt); + if (written) noteUsbLoggingTxComplete(); + return written; +} + +class PrimaryUartStream : public SingleAttemptNonBlockingStream { + public: + using SingleAttemptNonBlockingStream::SingleAttemptNonBlockingStream; + int available() override { + const int count = SingleAttemptNonBlockingStream::available(); + if (count > 0) notePrimaryUartInput(); + return count; + } + int read() override { + const int value = SingleAttemptNonBlockingStream::read(); + if (value >= 0) notePrimaryUartInput(); + return value; + } + int peek() override { + const int value = SingleAttemptNonBlockingStream::peek(); + if (value >= 0) notePrimaryUartInput(); + return value; + } +}; + +static PrimaryUartStream primary_uart_companion_port( + primarySerialPort(), writePrimaryUartOnce); +static AtomicWholeRecordNonBlockingStream<11> + primary_uart_mota_port(primary_uart_companion_port); +static BufferedNonBlockingWriteStream<4096> + buffered_primary_uart_terminal_port(primary_uart_companion_port); +#endif + #if MESH_ESP32_TINYUSB_NONBLOCKING // Arduino-ESP32 2.0.17 constructs Serial as USBCDC(0). Keep its RX queue and // existing descriptors/callbacks, but never call its potentially unbounded @@ -1263,6 +1321,9 @@ static WholeRecordNonBlockingStream<640> static AtomicWholeRecordNonBlockingStream<> nonblocking_primary_usb_logging_port( nonblocking_primary_usb_companion_port); + #elif MESH_PRIMARY_SERIAL_IS_UART +static AtomicWholeRecordNonBlockingStream<> + nonblocking_primary_usb_logging_port(primary_uart_companion_port); #else static WholeRecordNonBlockingStream<> nonblocking_primary_usb_logging_port(Serial); @@ -1464,7 +1525,7 @@ void beginUsbLoggingPort() { void serviceUsbLoggingPort() { #if MESH_ESP32_TINYUSB_NONBLOCKING \ - || (defined(NRF52_PLATFORM) && defined(USE_TINYUSB)) + || (defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART) // A timed reconnect retains all descriptors and never blocks the radio loop // in delay(), Serial.end(), or a host-progress wait. if (usb_logging_watchdog_detached @@ -1481,7 +1542,7 @@ void serviceUsbLoggingPort() { usb_logging_watchdog_detached = false; } #endif -#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING serviceNrf52UsbSessionReenumeration(); #endif #if MESH_ESP32_TINYUSB_NONBLOCKING @@ -1726,7 +1787,9 @@ Stream& usbDebugPort() { } Stream& usbCompanionPort() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return primary_uart_companion_port; +#elif MESH_ESP32_TINYUSB_NONBLOCKING return nonblocking_esp32_tinyusb_port; #elif MESH_ESP32_HWCDC_SESSION_GUARD return guarded_esp32_hwcdc_port; @@ -1739,12 +1802,23 @@ Stream& usbCompanionPort() { #if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) bool isUsbCompanionClientConnected() { +#if MESH_PRIMARY_SERIAL_IS_UART + if (primary_uart_companion_port.available() > 0) return true; + if (!primary_uart_has_rx) return false; + if (uint32_t(millis() - primary_uart_last_rx_at) < primary_uart_client_idle_ms) + return true; + primary_uart_has_rx = false; + return false; +#else return canAccessPrimaryUsbSession(nullptr); +#endif } #endif Stream& usbMotaPort() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return primary_uart_mota_port; +#elif MESH_ESP32_TINYUSB_NONBLOCKING return nonblocking_esp32_tinyusb_mota_port; #elif MESH_ESP32_HWCDC_SESSION_GUARD return guarded_esp32_hwcdc_mota_port; @@ -1757,7 +1831,9 @@ Stream& usbMotaPort() { static Stream& rawUsbTerminalPort(bool enabled) { if (!enabled) return null_usb_logging_stream; -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return buffered_primary_uart_terminal_port; +#elif MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port; #elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) return buffered_primary_usb_terminal_port; @@ -1767,7 +1843,9 @@ static Stream& rawUsbTerminalPort(bool enabled) { } static Stream& rawUsbConsolePort() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return buffered_primary_uart_terminal_port; +#elif MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port; #elif MESH_ESP32_HWCDC_SESSION_GUARD return guarded_esp32_hwcdc_port; @@ -1817,7 +1895,19 @@ bool canAcceptUsbConsoleCommand() { } void serviceUsbTerminalPort() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + if (primary_uart_terminal_discard_pending.exchange(false, std::memory_order_acq_rel) + && !buffered_primary_uart_terminal_port.tryDiscardPending()) { + primary_uart_terminal_discard_pending.store(true, std::memory_order_release); + return; + } + if (isUsbBinaryStreamOwned()) { + if (!buffered_primary_uart_terminal_port.tryDiscardPending()) + primary_uart_terminal_discard_pending.store(true, std::memory_order_release); + return; + } + buffered_primary_uart_terminal_port.service(); +#elif MESH_ESP32_TINYUSB_NONBLOCKING serviceEsp32TinyUsbPorts(); #elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) const uint32_t reset_generation = @@ -1843,7 +1933,10 @@ void serviceUsbTerminalPort() { } void discardUsbTerminalOutput() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + primary_uart_terminal_discard_pending.store(true, std::memory_order_release); + serviceUsbTerminalPort(); +#elif MESH_ESP32_TINYUSB_NONBLOCKING esp32_tinyusb_terminal_discard_pending.store(true, std::memory_order_release); if (esp32_tinyusb_queue_busy.test_and_set(std::memory_order_acquire)) return; esp32_tinyusb_text_queue.discardPending(); @@ -1858,7 +1951,10 @@ void discardUsbTerminalOutput() { } bool hasPendingUsbTerminalOutput() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return primary_uart_terminal_discard_pending.load(std::memory_order_acquire) + || buffered_primary_uart_terminal_port.queuedByteCount() != 0; +#elif MESH_ESP32_TINYUSB_NONBLOCKING if (esp32_tinyusb_queue_busy.test_and_set(std::memory_order_acquire)) return true; const bool pending = esp32_tinyusb_text_queue.queuedByteCount() != 0; esp32_tinyusb_queue_busy.clear(std::memory_order_release); @@ -1880,7 +1976,11 @@ uint32_t usbTerminalDroppedBytes() { } bool takeUsbTerminalSessionReset() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + // A UART bridge cannot expose the host's COM open/close or USB bus reset. + // The Companion parser uses bounded actual-input/frame activity instead. + return false; +#elif MESH_ESP32_TINYUSB_NONBLOCKING const uint32_t reset_generation = esp32_tinyusb_reset_generation.load(std::memory_order_acquire); if (reset_generation == esp32_tinyusb_taken_reset_generation) return false; @@ -1911,7 +2011,7 @@ bool takeUsbTerminalSessionReset() { #endif } -#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING static void completePrimaryUsbSessionReset(void*) { // This runs under the primary SingleAttempt stream's producer gate. It // removes a write that raced the owner-task close callback before allowing @@ -1928,7 +2028,9 @@ static void completePrimaryUsbSessionReset(void*) { #endif bool tryCompleteUsbTerminalSessionReset() { -#if MESH_ESP32_TINYUSB_NONBLOCKING +#if MESH_PRIMARY_SERIAL_IS_UART + return true; +#elif MESH_ESP32_TINYUSB_NONBLOCKING serviceEsp32TinyUsbPorts(); return esp32_tinyusb_clean_generation.load(std::memory_order_acquire) == esp32_tinyusb_reset_generation.load(std::memory_order_acquire); @@ -1990,6 +2092,9 @@ bool usbLoggingInterfaceRestartRequired() { } const char* usbLoggingPortDescription() { +#if MESH_PRIMARY_SERIAL_IS_UART + return "primary USB-to-UART bridge (tty/COM name is host-assigned)"; +#endif #if defined(MESH_DUAL_CDC_LOGGING) if (hasDedicatedUsbLoggingPort()) return "dedicated USB CDC 1, interface 02 (Linux: *-if02; tty/COM name is host-assigned)"; @@ -2044,7 +2149,7 @@ UsbLoggingObservation observeUsbLoggingTransport() { || meshEsp32HwcdcTxPending() #endif ; -#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART result.supported = true; const uint8_t instance = hasDedicatedUsbLoggingPort() ? 1 : 0; result.host_connected = tud_mounted(); @@ -2069,11 +2174,12 @@ void probeUsbLoggingTransport() { #endif static const uint8_t record[] = "[USB watchdog] heartbeat\r\n"; #if MESH_ESP32_TINYUSB_NONBLOCKING || MESH_ESP32_HWCDC_SESSION_GUARD \ - || defined(MESH_DUAL_CDC_LOGGING) || MESH_NRF52_USB_CONSOLE_COOPERATIVE + || defined(MESH_DUAL_CDC_LOGGING) || MESH_NRF52_USB_CONSOLE_COOPERATIVE \ + || MESH_PRIMARY_SERIAL_IS_UART Stream& port = usbLoggingPort(); if (port.availableForWrite() >= int(sizeof(record) - 1)) (void)port.write(record, sizeof(record) - 1); -#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART // Ordinary nRF52 roles may still use the framework Serial facade. Never // call its retrying write path from the watchdog: use one native attempt. if (tud_cdc_n_connected(0) @@ -2117,7 +2223,7 @@ UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { } } return UsbLoggingRecoveryResult::Attempted; -#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART if (stage == 1) { #if defined(MESH_DUAL_CDC_LOGGING) if (hasDedicatedUsbLoggingPort()) { @@ -2145,7 +2251,9 @@ UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { } bool isUsbLoggingTransportRecoveryPending() { -#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#if MESH_PRIMARY_SERIAL_IS_UART + return false; +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) return usb_logging_watchdog_detached || nrf52_usb_reenumeration_pending.load(std::memory_order_acquire); #elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) @@ -2188,7 +2296,7 @@ extern "C" bool meshEsp32HwcdcShouldReportBusReset() { } #endif -#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART extern "C" void meshTinyUsbLoggingTxComplete(uint8_t instance) { if (instance == (mesh::hasDedicatedUsbLoggingPort() ? 1 : 0)) mesh::noteUsbLoggingTxComplete(); @@ -2229,7 +2337,7 @@ extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted) { } #endif -#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING // A protocol-only, weak-declaration-free translation unit supplies strong // versions of TinyUSB's callbacks and forwards here. Preserve Adafruit's CDC0 // 1200-baud touch behavior exactly, capture every CDC0 host-session close and diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index 693378895..9496fa67d 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -2,6 +2,7 @@ #include "UsbLoggingStatus.h" #include "HostKissConfig.h" +#include "PrimarySerialBackend.h" // Canonical USB-capable images compile at least one of these two diagnostics. // A master USB output gate and a separate debug-verbosity gate keep packet @@ -44,13 +45,13 @@ #define MESH_ESP32_USB_CONSOLE_COOPERATIVE 0 #endif -#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) && !MESH_PRIMARY_SERIAL_IS_UART #define MESH_NRF52_USB_CONSOLE_COOPERATIVE 1 #else #define MESH_NRF52_USB_CONSOLE_COOPERATIVE 0 #endif #define MESH_USB_CONSOLE_COOPERATIVE \ - (MESH_ESP32_USB_CONSOLE_COOPERATIVE || MESH_NRF52_USB_CONSOLE_COOPERATIVE) + (MESH_ESP32_USB_CONSOLE_COOPERATIVE || MESH_NRF52_USB_CONSOLE_COOPERATIVE || MESH_PRIMARY_SERIAL_IS_UART) namespace mesh { @@ -165,7 +166,8 @@ Stream& usbDebugPort(); // Primary USB Companion data stream. Native TinyUSB on nRF52 and ESP32 uses // one FIFO attempt per write, never the framework's wait-for-space loop. // Callers retain and retry unwritten suffixes. Other platforms retain Serial -// or their hardware-CDC session facade. +// or their hardware-CDC session facade. An explicit MESH_PRIMARY_UART_PORT +// selects that wired bridge without native-USB line-state/session assumptions. Stream& usbCompanionPort(); // A primary nRF52 client may send commands with DTR low (stock MeshCLI does). // Accept actual input in the current USB session as proof of a reader, while diff --git a/src/helpers/UsbLoggingLineStateOverride.cpp b/src/helpers/UsbLoggingLineStateOverride.cpp index 6f5de5207..4a5df4d94 100644 --- a/src/helpers/UsbLoggingLineStateOverride.cpp +++ b/src/helpers/UsbLoggingLineStateOverride.cpp @@ -4,7 +4,8 @@ // strong callbacks which override Adafruit's weak defaults for nRF52 USB // Companion builds. CDC0 needs exact close events even on single-CDC targets; // dual-CDC builds additionally forward CDC1 reconnect events. -#if defined(ARDUINO) && defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +#if defined(ARDUINO) && defined(NRF52_PLATFORM) && defined(USE_TINYUSB) \ + && !defined(MESH_PRIMARY_UART_PORT) #include extern "C" void meshTinyUsbLoggingTxComplete(uint8_t instance); // No weak callback declaration is imported in this translation unit. @@ -14,7 +15,8 @@ extern "C" void tud_cdc_tx_complete_cb(uint8_t instance) { #endif #if defined(ARDUINO) && defined(NRF52_PLATFORM) \ - && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) + && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)) \ + && !defined(MESH_PRIMARY_UART_PORT) #include extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr, diff --git a/src/helpers/UsbMode.h b/src/helpers/UsbMode.h index a81db1eb5..1abb4cddc 100644 --- a/src/helpers/UsbMode.h +++ b/src/helpers/UsbMode.h @@ -25,6 +25,9 @@ inline const char* usbModeName() { return WithPrefix ? "usb.mode tinyusb" : "tinyusb"; #endif #endif +#elif defined(NRF52_PLATFORM) && defined(MESH_PRIMARY_UART_PORT) + if (std::is_same::value) + return WithPrefix ? "usb.mode uart" : "uart"; #elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) if (std::is_same::value) return WithPrefix ? "usb.mode tinyusb" : "tinyusb"; diff --git a/test/fixtures/nrf52_uart_console/mocks/Adafruit_TinyUSB.h b/test/fixtures/nrf52_uart_console/mocks/Adafruit_TinyUSB.h new file mode 100644 index 000000000..57571a060 --- /dev/null +++ b/test/fixtures/nrf52_uart_console/mocks/Adafruit_TinyUSB.h @@ -0,0 +1 @@ +#error "An explicit primary UART must not compile or query the native USB backend" diff --git a/test/fixtures/nrf52_uart_console/mocks/Arduino.h b/test/fixtures/nrf52_uart_console/mocks/Arduino.h new file mode 100644 index 000000000..da6a3cacb --- /dev/null +++ b/test/fixtures/nrf52_uart_console/mocks/Arduino.h @@ -0,0 +1,42 @@ +#pragma once +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wsign-compare" +#include "../../../mocks/Arduino.h" +#pragma GCC diagnostic pop +#include +#include +#include +#include + +inline bool isInISR() { return false; } +class MockUart : public Stream { + public: + std::deque input; + std::vector output; + int capacity = 0; + std::function duringWrite; + int available() override { return input.size(); } + int read() override { if (input.empty()) return -1; const int value=input.front();input.pop_front();return value; } + int peek() override { return input.empty() ? -1 : input.front(); } + int availableForWrite() override { return capacity; } + size_t write(uint8_t byte) override { return write(&byte,1); } + size_t write(const uint8_t* data, size_t size) override { + if (duringWrite) { auto callback=duringWrite;duringWrite={};callback(); } + assert(size <= static_cast(capacity)); + output.insert(output.end(),data,data+size);capacity-=size;return size; + } + void flush() override { assert(false && "UART facade must not wait for wire drain"); } +}; +class UnusedNativeUsb : public Stream { + public: + int available() override { assert(false);return 0; } + int read() override { assert(false);return -1; } + int peek() override { assert(false);return -1; } + int availableForWrite() override { assert(false);return 0; } + size_t write(uint8_t) override { assert(false);return 0; } + size_t write(const uint8_t*,size_t) override { assert(false);return 0; } + void flush() override { assert(false); } +}; +extern MockUart Serial1; +extern UnusedNativeUsb Serial; diff --git a/test/fixtures/nrf52_uart_console/mocks/class/cdc/cdc.h b/test/fixtures/nrf52_uart_console/mocks/class/cdc/cdc.h new file mode 100644 index 000000000..bd2c617c7 --- /dev/null +++ b/test/fixtures/nrf52_uart_console/mocks/class/cdc/cdc.h @@ -0,0 +1 @@ +#error "A UART bridge must not override unrelated native USB session callbacks" diff --git a/test/fixtures/nrf52_uart_console/test_console.cpp b/test/fixtures/nrf52_uart_console/test_console.cpp new file mode 100644 index 000000000..7a098daed --- /dev/null +++ b/test/fixtures/nrf52_uart_console/test_console.cpp @@ -0,0 +1,96 @@ +#include +#include +#include +#include +#include +#include +#include +MockUart Serial1; +UnusedNativeUsb Serial; +void yield() {} +static constexpr uint32_t USB_CLIENT_IDLE_TIMEOUT=10*60*1000UL; +struct FrameLease { + bool received=false; + uint32_t at=0; + std::function connected; + bool hasReceivedFrame() const { return received; } + uint32_t getLastFrameMillis() const { return at; } + void setConnectedCheck(std::function callback) { connected=callback; } +} usb_serial_interface; + +int main() { + static_assert(MESH_PRIMARY_SERIAL_IS_UART, "explicit UART required"); + static_assert(!MESH_NRF52_USB_CONSOLE_COOPERATIVE, "not a native USB backend"); + assert(!strcmp(mesh::usbModeName(), "uart")); + assert(!strcmp(mesh::usbModeName(), "unknown")); + assert(!mesh::isUsbLoggingEnabled()); + mesh::beginUsbLoggingPort();mesh::serviceUsbLoggingPort(); + assert(!mesh::hasDedicatedUsbLoggingPort()); + assert(!mesh::isUsbCompanionClientConnected()); + @BINARY_CONNECTION@ + assert(!usb_serial_interface.connected()); + assert(!mesh::takeUsbTerminalSessionReset()); + assert(mesh::tryCompleteUsbTerminalSessionReset()); + assert(!mesh::isUsbLoggingTransportRecoveryPending()); + assert(mesh::recoverUsbLoggingTransport(1)==mesh::UsbLoggingRecoveryResult::Unsupported); + + auto& binary=mesh::usbCompanionPort(); + Serial1.input={0x12,0x34}; + assert(mesh::isUsbCompanionClientConnected()); + assert(!usb_serial_interface.connected()); + usb_serial_interface.received=true; + assert(usb_serial_interface.connected()); + assert(binary.peek()==0x12);assert(binary.available()==2); + assert(binary.read()==0x12);assert(binary.read()==0x34); + g_mock_millis=600001;assert(!mesh::isUsbCompanionClientConnected()); + assert(!usb_serial_interface.connected()); + g_mock_millis=0;assert(!mesh::isUsbCompanionClientConnected()); + g_mock_millis=UINT32_MAX-5;Serial1.input={0x56}; + assert(binary.read()==0x56);g_mock_millis=10; + assert(mesh::isUsbCompanionClientConnected()); + g_mock_millis=600010;assert(!mesh::isUsbCompanionClientConnected()); + + const uint8_t frame[]={1,2,3,4};Serial1.capacity=2; + assert(binary.write(frame,sizeof(frame))==2); + assert(binary.write(frame,sizeof(frame))==0);binary.flush(); + auto& terminal=mesh::usbTerminalPort(); + assert(terminal.write(frame,sizeof(frame))==sizeof(frame)); + assert(mesh::hasPendingUsbTerminalOutput()); + Serial1.capacity=2;mesh::serviceUsbTerminalPort(); + assert(mesh::hasPendingUsbTerminalOutput()); + Serial1.capacity=2;mesh::serviceUsbTerminalPort(); + assert(!mesh::hasPendingUsbTerminalOutput()); + + assert(terminal.write(frame,sizeof(frame))==sizeof(frame)); + mesh::setUsbBinaryStreamOwned(true); + assert(!mesh::hasPendingUsbTerminalOutput()); + const auto count=Serial1.output.size(); + assert(terminal.write(frame,sizeof(frame))==sizeof(frame)); + Serial1.capacity=64;mesh::serviceUsbTerminalPort(); + assert(Serial1.output.size()==count); + mesh::setUsbLoggingEnabled(true);mesh::setUsbDebugEnabled(true); + assert(mesh::usbLoggingPort().write(frame,sizeof(frame))==sizeof(frame)); + assert(Serial1.output.size()==count); + mesh::setUsbBinaryStreamOwned(false); + assert(mesh::usbDebugPort().write(frame,sizeof(frame))==sizeof(frame)); + assert(Serial1.output.size()==count+sizeof(frame)); + assert(mesh::isUsbLoggingEnabled()&&mesh::isUsbDebugEnabled()); + + auto& mota=mesh::usbMotaPort();Serial1.capacity=3; + assert(mota.write(frame,sizeof(frame))==0); + Serial1.capacity=4;assert(mota.write(frame,sizeof(frame))==sizeof(frame)); + auto& logger=mesh::usbLoggingPort(); + logger.flush(); // Must never forward HardwareSerial::flush's wire wait. + const auto before_reentrant=Serial1.output.size(); + Serial1.capacity=4; + Serial1.duringWrite=[&]() { assert(logger.write(frame,sizeof(frame))==0); }; + assert(binary.write(frame,sizeof(frame))==sizeof(frame)); + assert(Serial1.output.size()==before_reentrant+sizeof(frame)); + Serial1.capacity=4; + Serial1.duringWrite=[&]() { assert(binary.write(frame,sizeof(frame))==0); }; + assert(logger.write(frame,sizeof(frame))==sizeof(frame)); + assert(Serial1.output.size()==before_reentrant+2*sizeof(frame)); + assert(!mesh::observeUsbLoggingTransport().supported); + assert(strstr(mesh::usbLoggingPortDescription(), "UART")); + puts("nRF52 UART bridge console transport passed"); +} diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 86a42c165..c8400f15d 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -510,6 +510,31 @@ picker.applyFullCompanionCapabilities([nrfUf2OnlyFull]); assert.strictEqual(nrfUf2OnlyFull.dedicatedUsbLogging, true); assert.strictEqual(nrfUf2OnlyFull.logging, "usb-runtime"); +// WM1110's wired companion is a single USB-UART bridge. DFU packaging and +// absent/stale release controls must never advertise a second CDC interface. +for (const kinds of [["zip", "uf2"], ["zip"], ["uf2"], ["bin"]]) { + const wm1110 = Object.assign(picker.parseTargetProfile("wio_wm1110_companion_radio_full"), + {installKinds: kinds}); + picker.applyFullCompanionCapabilities([wm1110]); + assert.strictEqual(wm1110.dedicatedUsbLogging, false); + assert(!picker.installSteps(wm1110, kinds[0]).join("\n").includes("interface 02")); +} +for (const dedicated of [undefined, false, true]) { + const wmSource = "abcd1234" + "0".repeat(32), wmTarget = "wio_wm1110_companion_radio_full"; + const wmReleases = [release(family, "2026-08-23T12:00:00Z", + [asset(wmTarget + "-" + family + ".zip"), asset(wmTarget + "-" + family + ".uf2")])]; + const wmControls = {familyTag: family, source: wmSource, profiles: {[wmTarget]: { + platform: "NRF52_PLATFORM", dedicatedUsbLogging: dedicated, + dedicatedUsbLoggingSource: wmSource, + wm1110CombinedCompanion: true, wm1110CombinedCompanionSource: wmSource, + }}}; + const wmProfile = picker.buildCatalog(wmReleases, wmControls).profiles[0]; + assert.strictEqual(wmProfile.dedicatedUsbLogging, false); + const wmDirections = picker.installSteps(wmProfile, "zip").join("\n"); + assert(!wmDirections.includes("interface 02")); + assert(wmDirections.includes("starts in its ASCII terminal")); +} + const g2RxBoosted = picker.parseTargetProfile( "Station_G2_logging_repeater" ); diff --git a/test/test_nrf52_uart_console.py b/test/test_nrf52_uart_console.py new file mode 100644 index 000000000..e48f8204c --- /dev/null +++ b/test/test_nrf52_uart_console.py @@ -0,0 +1,37 @@ +"""Exercise explicit nRF52 UART routing without any native USB implementation.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] +FIXTURE=ROOT/'test/fixtures/nrf52_uart_console' + +class Nrf52UartConsoleTest(unittest.TestCase): + def test_actual_uart_transport_and_native_usb_exclusion(self): + compiler=shutil.which('g++') or shutil.which('clang++') + if not compiler: + self.skipTest('a host C++17 compiler is required') + sanitizer=[] if os.name=='nt' else ['-fsanitize=address,undefined','-fno-sanitize-recover=all','-fno-pie','-no-pie'] + with tempfile.TemporaryDirectory(prefix='nrf52-uart-console-') as directory: + binary=Path(directory)/'uart-console' + main=(ROOT/'examples/companion_radio/main.cpp').read_text() + branch=main.split('#elif MESH_PRIMARY_SERIAL_IS_UART\n // No native DTR',1)[1].split('#elif defined(NRF52_PLATFORM)',1)[0] + callback=branch[branch.index(' usb_serial_interface.setConnectedCheck'):] + source=Path(directory)/'test_console.cpp' + source.write_text((FIXTURE/'test_console.cpp').read_text().replace('@BINARY_CONNECTION@',callback)) + built=subprocess.run([compiler,'-std=c++17','-Wall','-Wextra','-Werror',*sanitizer, + '-DARDUINO','-DNRF52_PLATFORM','-DUSE_TINYUSB','-DENABLE_USB_INTERFACE', + '-DMESH_DEBUG=1','-DCOMPANION_RADIO_FULL=1','-DMESH_PRIMARY_UART_PORT=Serial1', + '-I'+str(FIXTURE/'mocks'),'-I'+str(ROOT/'src'),str(source), + *[str(ROOT/'src/helpers'/file) for file in ('UsbLogging.cpp','UsbLoggingClientActivity.cpp','UsbLoggingLineStateOverride.cpp')], + '-o',str(binary)],capture_output=True,text=True,timeout=60) + self.assertEqual(built.returncode,0,built.stdout+built.stderr) + tested=subprocess.run([str(binary)],capture_output=True,text=True,timeout=10) + self.assertEqual(tested.returncode,0,tested.stdout+tested.stderr) + self.assertIn('UART bridge console transport passed',tested.stdout) + +if __name__=='__main__': + unittest.main() diff --git a/test/test_picker_controls_generator.py b/test/test_picker_controls_generator.py index 12170c667..bc114820c 100644 --- a/test/test_picker_controls_generator.py +++ b/test/test_picker_controls_generator.py @@ -13,6 +13,36 @@ SPEC.loader.exec_module(GENERATOR) class PickerControlsTests(unittest.TestCase): + def test_wm1110_single_uart_logging_is_explicit_and_release_bound(self): + from copy import deepcopy + source = 'a' * 40 + marker = 'meshcore.capability.companion.wm1110.combined.v1' + manifest = dict(target='wio_wm1110_companion_radio_full', platform='NRF52_PLATFORM', + verified=True, source_commit=source, + capabilities=['companion.wm1110.combined', 'companion.usb', 'companion.bluetooth'], + verification=[dict(capability='companion.wm1110.combined', present=True, + source='packaged application', evidence=marker), + *[dict(capability=cap, present=True, source='linked image', + evidence='linked transport') + for cap in ('companion.usb', 'companion.bluetooth')]]) + self.assertEqual(GENERATOR.runtime_metadata(manifest, False, source), dict( + otaRole='none', dedicatedUsbLogging=False, dedicatedUsbLoggingSource=source)) + for changes in (dict(target='other_companion_radio_full'), dict(platform='ESP32_PLATFORM'), + dict(verified=False), dict(verification=[]), dict(source_commit='b' * 40), + dict(source_commit='bad-source'), + dict(capabilities=manifest['capabilities'] + ['companion.dedicated_usb_logging'])): + with self.subTest(changes=changes), self.assertRaisesRegex(ValueError, 'WM1110'): + GENERATOR.runtime_metadata({**manifest, **changes}, False, source) + for index in range(len(manifest['verification'])): + for changes in (dict(present=False), dict(source='config'), dict(evidence='')): + invalid = deepcopy(manifest) + invalid['verification'][index].update(changes) + with self.subTest(index=index, changes=changes), self.assertRaisesRegex(ValueError, 'WM1110'): + GENERATOR.runtime_metadata(invalid, False, source) + unknown = dict(target='wio_wm1110_companion_radio_full', platform='NRF52_PLATFORM', + verified=True, capabilities=[], verification=[]) + self.assertNotIn('dedicatedUsbLogging', GENERATOR.runtime_metadata(unknown, False, source)) + def test_shared_radio_keeps_supported_runtime_rxps_control(self): shared = dict(RADIO_CLASS='UniversalLoRaRadio', MESH_RADIO_AUTO_FAMILY='1', USE_SX1262='1') self.assertTrue(GENERATOR.has_rxps_control(shared)) diff --git a/test/test_wm1110_consolidation.py b/test/test_wm1110_consolidation.py new file mode 100644 index 000000000..b10c18c59 --- /dev/null +++ b/test/test_wm1110_consolidation.py @@ -0,0 +1,117 @@ +"""WM1110 retirement requires the exact source-bound combined application.""" +from copy import deepcopy +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import consolidated_profiles as consolidated + + +TARGET = "wio_wm1110_companion_radio_full" +LEGACY = "wio_wm1110_companion_radio_ble" +SOURCE = "a" * 40 +BOARD = "seeed-xiao-afruitnrf52-nrf52840" +CAPABILITY = "companion.wm1110.combined" +MARKER = "meshcore.capability.companion.wm1110.combined.v1" +COUNTS = dict(MAX_CONTACTS=350, MAX_GROUP_CHANNELS=40, OFFLINE_QUEUE_SIZE=256) +BASE_FLAGS = ["-DWIO_WM1110=1", "-DBLE_PIN_CODE=123456", + *("-D" + key + "=" + str(value) for key, value in COUNTS.items())] +FULL_FLAGS = [*BASE_FLAGS, "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1", + "-DMESH_PRIMARY_UART_PORT=Serial1"] + + +def configuration(flags): + return dict(board=BOARD, build_flags=list(flags)) + + +def records(): + old = dict(target=LEGACY, platform="NRF52_PLATFORM", build_profile="standard", + verified=True, source_commit=SOURCE, capabilities=["sensor.profile.full"], + verification=[]) + current = dict(target=TARGET, platform="NRF52_PLATFORM", build_profile="full", + verified=True, source_commit=SOURCE, + capabilities=["sensor.profile.full", CAPABILITY, + "companion.usb", "companion.bluetooth"], + verification=[dict(capability=CAPABILITY, present=True, + source="packaged application", evidence=MARKER), + *[dict(capability=key, present=True, source="linked image", + evidence="actual linked transport") + for key in ("companion.usb", "companion.bluetooth")]]) + return dict(manifest=old, files=[LEGACY + ".zip"]), dict(manifest=current, files=[TARGET + ".zip"]) + + +class Wm1110ConsolidationTests(unittest.TestCase): + def test_exact_full_recipe_and_packaged_marker_produce_source_bound_metadata(self): + current = records()[1]["manifest"] + self.assertEqual(consolidated.runtime_metadata(current, configuration(FULL_FLAGS), SOURCE), + dict(wm1110CombinedCompanion=True, wm1110CombinedCompanionSource=SOURCE)) + for flag in FULL_FLAGS: + key = flag[2:].split("=", 1)[0] + with self.subTest(removed=key), self.assertRaises(ValueError): + consolidated.runtime_metadata(current, configuration(FULL_FLAGS + ["-U" + key]), SOURCE) + for key, value in COUNTS.items(): + with self.subTest(reduced=key), self.assertRaises(ValueError): + consolidated.runtime_metadata(current, configuration(FULL_FLAGS + [f"-D{key}={value - 1}"]), SOURCE) + for changes in (dict(board="unreviewed"), + dict(build_flags=FULL_FLAGS + ["-DMESH_PRIMARY_UART_PORT=Serial"]), + dict(build_flags=FULL_FLAGS + ["-DWIO_WM1110=0"])): + with self.subTest(changes=changes), self.assertRaises(ValueError): + consolidated.runtime_metadata(current, {**configuration(FULL_FLAGS), **changes}, SOURCE) + + def test_exact_ble_recipe_has_one_full_successor_with_no_capacity_reduction(self): + envs = {"env:" + TARGET: configuration(FULL_FLAGS), "env:" + LEGACY: configuration(BASE_FLAGS)} + self.assertEqual(consolidated.replacements(LEGACY), [(TARGET, "wm1110CombinedCompanion")]) + self.assertEqual(consolidated.configuration_replacement(LEGACY, envs), TARGET) + for name in (LEGACY + "_custom", LEGACY + "_ps", "wio_wm1110_companion_radio_usb", + "other_wm1110_companion_radio_ble", "wio_wm1110_repeater", TARGET): + with self.subTest(name=name): + self.assertEqual(consolidated.replacements(name), []) + self.assertIsNone(consolidated.configuration_replacement(name, envs)) + self.assertIsNone(consolidated.configuration_replacement(LEGACY, {"env:" + LEGACY: envs["env:" + LEGACY]})) + for key, value in COUNTS.items(): + changed = deepcopy(envs) + changed["env:" + LEGACY]["build_flags"].append(f"-D{key}={value + 1}") + with self.subTest(reduced=key): + self.assertIsNone(consolidated.configuration_replacement(LEGACY, changed)) + changed["env:" + TARGET]["build_flags"].append(f"-D{key}={value + 1}") + self.assertEqual(consolidated.configuration_replacement(LEGACY, changed), TARGET) + for which, change in ((TARGET, dict(board="other")), (LEGACY, dict(board="other")), + (LEGACY, dict(build_flags=BASE_FLAGS + ["-UWIO_WM1110"]))): + changed = deepcopy(envs) + changed["env:" + which].update(change) + with self.subTest(which=which, change=change): + self.assertIsNone(consolidated.configuration_replacement(LEGACY, changed)) + + def test_only_a_full_same_source_same_sensors_combined_package_retires_old_ble(self): + old, current = records() + original = deepcopy([old, current]) + self.assertEqual(consolidated.omit_redundant_records([old, current]), [current]) + self.assertEqual([old, current], original) + self.assertEqual(consolidated.omit_redundant_records([old]), [old]) + changes = [dict(verified=False), dict(platform="ESP32_PLATFORM"), + dict(build_profile="standard"), dict(source_commit="b" * 40), + dict(source_commit=None), dict(target=TARGET + "_custom"), + dict(capabilities=current["manifest"]["capabilities"] + ["hardware.qualification.pending"]), + dict(capabilities=current["manifest"]["capabilities"] + ["sensor.profile.reduced"])] + for cap in (CAPABILITY, "companion.usb", "companion.bluetooth"): + changes.append(dict(capabilities=[key for key in current["manifest"]["capabilities"] if key != cap])) + for change in (dict(present=False), dict(source="config"), dict(evidence="")): + changes.append(dict(verification=[{**row, **change} if row["capability"] == cap else row + for row in current["manifest"]["verification"]])) + changes.append(dict(verification=[{**row, "evidence": MARKER + ".old"} + if row["capability"] == CAPABILITY else row + for row in current["manifest"]["verification"]])) + for change in changes: + wrong = {**current, "manifest": {**current["manifest"], **change}} + with self.subTest(change=change): + self.assertEqual(consolidated.omit_redundant_records([old, wrong]), [old, wrong]) + for source in (None, "A" * 40, "a" * 39, "b" * 40): + wrong = {**old, "manifest": {**old["manifest"], "source_commit": source}} + with self.subTest(old_source=source): + self.assertEqual(consolidated.omit_redundant_records([wrong, current]), [wrong, current]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wm1110_full_profile.py b/test/test_wm1110_full_profile.py new file mode 100644 index 000000000..02dd3c30f --- /dev/null +++ b/test/test_wm1110_full_profile.py @@ -0,0 +1,78 @@ +"""WM1110 Full retains BLE while using the real CH340 primary UART.""" +import configparser +import os +from pathlib import Path +import shlex +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) +CONFIG.read(ROOT / "platformio.ini") +CONFIG.read(ROOT / "variants/wio_wm1110/platformio.ini") +TARGET = "wio_wm1110_companion_radio_full" + + +class Wm1110FullProfileTests(unittest.TestCase): + def test_full_is_an_explicit_exact_board_child_of_the_unchanged_ble_recipe(self): + base = CONFIG["env:wio_wm1110_companion_radio_ble"] + full = CONFIG["env:" + TARGET] + self.assertEqual(full["extends"], "env:wio_wm1110_companion_radio_ble") + self.assertEqual(base["board_build.ldscript"], "boards/nrf52840_s140_v7_extrafs.ld") + self.assertEqual(base["board_upload.maximum_size"], "708608") + self.assertNotIn("MESH_PRIMARY_UART_PORT", base["build_flags"]) + self.assertNotIn("ENABLE_USB_INTERFACE", base["build_flags"]) + flags = shlex.split(full["build_flags"]) + for flag in ("COMPANION_RADIO_FULL=1", "ENABLE_USB_INTERFACE=1", + "MESH_PRIMARY_UART_PORT=Serial1", "ENABLE_OTA=1", + "OTA_SEEDER_ONLY=1", "MOTA_TARGET_ID=0", "OTA_FOLDER_SERIAL=1", + "COMPANION_FEATURE_USB_MOTA_SOURCE=1", "COMPANION_FEATURE_BLE_MOTA_SOURCE=1", + "OTA_SHARED_COMPANION_QUEUE=1", "OTA_TARGET_NAME_FRONT_CODED=1", + "MESH_DEBUG=1", "MESH_PACKET_LOGGING=1", "SERIAL_BUFFER_SIZE=1024"): + self.assertIn(flag, flags) + for value in ("MAX_CONTACTS=350", "MAX_GROUP_CHANNELS=40", "OFFLINE_QUEUE_SIZE=256", + "BLE_PIN_CODE=123456"): + self.assertIn(value, shlex.split(base["build_flags"])) + self.assertIn("helpers/ota/*.cpp", full["build_src_filter"]) + self.assertNotIn("MESH_DUAL_CDC_LOGGING", full["build_flags"]) + self.assertNotIn("CFG_TUD_CDC=2", full["build_flags"]) + + @unittest.skipIf(os.name == "nt", "sourced Bash policy runs under Linux/WSL") + def test_full_build_overlay_never_invents_a_second_usb_endpoint_for_uart(self): + script = r''' +set -euo pipefail +pio() { echo 'unexpected PIO invocation' >&2; return 99; } +source ./build.sh +target=$1 +PIO_ENV_PLATFORM_BY_NAME[$target]=NRF52_PLATFORM +PIO_ENV_BUILD_BASE_BY_NAME[$target]=$target +printf -v PIO_CONFIG_JSON '[ [ "env:%s", [ [ "build_flags", [ "%s" ] ], [ "build_src_filter", [ "+", "+" ] ] ] ] ]' "$target" "$2" +PLATFORMIO_BUILD_FLAGS= +PLATFORMIO_BUILD_UNFLAGS= +BUILD_REDUCTIONS=() +MESHDEBUG_OVERRIDE= +PACKET_LOGGING_OVERRIDE= +MQTT_BRIDGE_OVERRIDE= +apply_companion_radio_full_profile "$target" "$target" +printf '%s' "$PLATFORMIO_BUILD_FLAGS" +''' + for target, configured, dual in ( + (TARGET, "-DMESH_PRIMARY_UART_PORT=Serial1", False), + ("t1000e_companion_radio_full", "", False), + ("Xiao_nrf52_companion_radio_full", "", True), + ): + result = subprocess.run(["bash", "-c", script, "wm1110-policy", target, configured], + cwd=ROOT, text=True, capture_output=True, check=True) + with self.subTest(target=target): + flags = shlex.split(result.stdout) + self.assertIn("-DENABLE_USB_INTERFACE=1", flags) + self.assertIn("-DBLE_PIN_CODE=123456", flags) + self.assertIn("-DCOMPANION_FEATURE_USB_MOTA_SOURCE=1", flags) + self.assertIn("-DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1", flags) + self.assertEqual("-DMESH_DUAL_CDC_LOGGING=1" in flags, dual) + self.assertEqual("-DCFG_TUD_CDC=2" in flags, dual) + self.assertEqual("-DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1" in flags, dual) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/wio_wm1110/platformio.ini b/variants/wio_wm1110/platformio.ini index 2dc49aaff..49ae99857 100644 --- a/variants/wio_wm1110/platformio.ini +++ b/variants/wio_wm1110/platformio.ini @@ -92,6 +92,34 @@ lib_deps = ${wio_wm1110.lib_deps} densaugeo/base64 @ ~1.4.0 +; The dev kit's computer connection is CH340C -> UART0 (Arduino Serial1), +; not the nRF52840's native USB CDC port. Keep the historical BLE image exact; +; new installations can use both Companion transports in this Full image. +[env:wio_wm1110_companion_radio_full] +extends = env:wio_wm1110_companion_radio_ble +build_flags = + ${env:wio_wm1110_companion_radio_ble.build_flags} + -D COMPANION_RADIO_FULL=1 + -D ENABLE_USB_INTERFACE=1 + -D MESH_PRIMARY_UART_PORT=Serial1 + -D ENABLE_OTA=1 + -D OTA_SEEDER_ONLY=1 + -D MOTA_TARGET_ID=0 + -D OTA_FOLDER_SERIAL=1 + -D COMPANION_FEATURE_USB_MOTA_SOURCE=1 + -D COMPANION_FEATURE_BLE_MOTA_SOURCE=1 + -D OTA_SHARED_COMPANION_QUEUE=1 + -D OTA_TARGET_NAME_FRONT_CODED=1 + -D CFG_DEBUG=1 + -D RECOVERABLE_EXTERNAL_RADIO=1 + -D MESH_DEBUG=1 + -D MESH_PACKET_LOGGING=1 + ; Stock 64-byte UART RX lasts only 5.5 ms at 115200 baud. Keep complete + ; Companion frames and CLI lines across normal radio scheduling intervals. + -D SERIAL_BUFFER_SIZE=1024 +build_src_filter = ${env:wio_wm1110_companion_radio_ble.build_src_filter} + + + [env:wio_wm1110_kiss_modem] extends = wio_wm1110 build_src_filter = ${wio_wm1110.build_src_filter}