diff --git a/build.sh b/build.sh index 6ed9cda6..9202beda 100755 --- a/build.sh +++ b/build.sh @@ -182,7 +182,10 @@ Environment Variables: If not set, build.sh first refreshes tags from upstream when configured (otherwise origin), then derives a default from the latest matching tag and appends "-dev". - In interactive builds, this value is offered as the editable default. + In interactive builds, an existing version detected in + OUTPUT_DIR is offered in a use-or-edit menu. If no + artifact version is found, the derived version is + offered directly as the editable default. A single custom version suffix found in existing OUTPUT_DIR artifacts is carried forward after the new numeric version. DISABLE_DEBUG=1: Disables all debug logging flags (MESH_DEBUG, MESH_PACKET_LOGGING, etc.) @@ -1586,6 +1589,77 @@ apply_output_firmware_version_suffix() { fi } +extract_firmware_version_from_artifact_filename() { + local filename=${1##*/} + local stem + local version + local i + local -a filename_parts=() + + case "$filename" in + *.capabilities.json) + stem=${filename%.capabilities.json} + ;; + *.bin|*.hex|*.uf2|*.zip) + stem=${filename%.*} + stem=${stem%-merged} + ;; + *) + return 1 + ;; + esac + + # Every collected artifact ends in the source commit. Remove it first so a + # hyphenated prerelease/custom version can be returned intact. + if ! [[ "$stem" =~ ^(.+)-[[:xdigit:]]{7,40}$ ]]; then + return 1 + fi + stem=${BASH_REMATCH[1]} + + IFS='-' read -r -a filename_parts <<< "$stem" + for ((i = 0; i < ${#filename_parts[@]}; i++)); do + if [[ "${filename_parts[$i]}" =~ ^v?[0-9]+(\.[0-9]+){2,}$ ]] \ + || { [ "${filename_parts[$i]}" = "$FALLBACK_VERSION_PREFIX" ] \ + && [ $((i + 1)) -lt ${#filename_parts[@]} ] \ + && [[ "${filename_parts[$((i + 1))]}" =~ ^[0-9]{4}$ ]]; }; then + local IFS='-' + version="${filename_parts[*]:$i}" + printf '%s\n' "$version" + return 0 + fi + done + + return 1 +} + +get_latest_output_firmware_version() { + local output_dir=${1:-$OUTPUT_DIR} + local artifact_filename + local _timestamp + local version + + if ! [ -d "$output_dir" ]; then + return 1 + fi + + # Prefer the newest artifact when a resumed/partial build left more than one + # release in OUTPUT_DIR. Duplicate files from one build all resolve to the + # same version and are harmless. + while IFS=$'\t' read -r _timestamp artifact_filename; do + if version=$(extract_firmware_version_from_artifact_filename "$artifact_filename"); then + printf '%s\n' "$version" + return 0 + fi + done < <( + find "$output_dir" -maxdepth 1 -type f \ + \( -name '*.capabilities.json' -o -name '*.bin' -o -name '*.hex' \ + -o -name '*.uf2' -o -name '*.zip' \) \ + -printf '%T@\t%f\n' | sort -nr -k1,1 + ) + + return 1 +} + prompt_for_firmware_version() { local prompt_label=$1 local result_var=$2 @@ -1606,9 +1680,44 @@ prompt_for_firmware_version() { printf -v "$result_var" '%s' "${entered_version:-$suggested_version}" } +prompt_to_use_or_edit_output_version() { + local prompt_label=$1 + local result_var=$2 + local detected_version=$3 + local edited_version + local options=( + "Use detected version: ${detected_version}" + "Edit firmware version" + ) + + echo "Detected firmware version in ${OUTPUT_DIR}: ${detected_version}" + while true; do + print_numbered_menu "${options[@]}" + prompt_menu_choice "Firmware version" "${#options[@]}" + case "$MENU_CHOICE" in + 1) + printf -v "$result_var" '%s' "$detected_version" + echo "Using firmware version: ${detected_version}" + return 0 + ;; + 2) + prompt_for_firmware_version \ + "$prompt_label" edited_version "$detected_version" + printf -v "$result_var" '%s' "$edited_version" + return 0 + ;; + QUIT) + echo "Cancelled." + exit 1 + ;; + esac + done +} + prompt_for_resolved_firmware_version() { local prompt_label local selected_version=${FIRMWARE_VERSION:-} + local output_version="" if [ ${#RESOLVED_BUILD_TARGETS[@]} -eq 0 ]; then return 0 @@ -1623,18 +1732,23 @@ prompt_for_resolved_firmware_version() { return 0 fi - if [ -z "$selected_version" ]; then - selected_version=$(derive_default_firmware_version_for_targets "${RESOLVED_BUILD_TARGETS[@]}") - selected_version=$(apply_output_firmware_version_suffix "$selected_version") - fi - if [ ${#RESOLVED_BUILD_TARGETS[@]} -eq 1 ]; then prompt_label="${RESOLVED_BUILD_TARGETS[0]}" else prompt_label="${#RESOLVED_BUILD_TARGETS[@]} build targets" fi - prompt_for_firmware_version "$prompt_label" selected_version "$selected_version" + if [ -z "$selected_version" ] \ + && output_version=$(get_latest_output_firmware_version "$OUTPUT_DIR"); then + prompt_to_use_or_edit_output_version \ + "$prompt_label" selected_version "$output_version" + else + if [ -z "$selected_version" ]; then + selected_version=$(derive_default_firmware_version_for_targets "${RESOLVED_BUILD_TARGETS[@]}") + selected_version=$(apply_output_firmware_version_suffix "$selected_version") + fi + prompt_for_firmware_version "$prompt_label" selected_version "$selected_version" + fi FIRMWARE_VERSION=$selected_version export FIRMWARE_VERSION } @@ -2997,10 +3111,11 @@ apply_companion_radio_full_profile() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -UWEBCONFIG_DISABLED -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DCOMPANION_FEATURE_TEMP_RADIO=1 -DCOMPANION_FEATURE_OTA_CLI=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" if is_nrf52_companion_radio_full_target "$env_name"; then - # CDC 0 starts as Binary Companion. `motatool serve --serial` switches it - # into an exclusive host-folder mode with its existing `ota folder on` - # preamble. CDC 1 is a write-only plaintext packet/debug logging stream; - # BLE remains an independent Companion link. + # CDC 0 starts as an ASCII CLI and automatically hands an incoming framed + # command to Binary Companion. `motatool serve --serial` switches it into + # an exclusive host-folder mode with its existing `ota folder on` preamble. + # CDC 1 is a write-only plaintext packet/debug logging stream; BLE remains + # an independent Companion link. append_platformio_build_unflags "-UOTA_FOLDER_SERIAL" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 55f7e2a5..ec850f5a 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -72,8 +72,8 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM. | 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 on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. | | FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. | -| Dual-CDC Full Companion | nRF52 and qualified native-USB ESP32-S3 Full images use one physical USB connection. Fresh installs expose only interface `00` for framed Companion/terminal/mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. They also provide BLE and source-only LoRa OTA; ESP32 additionally provides WiFi. `get/set usb.logging` persistently controls whether the logging interface is present. | -| Single-TTY Full Companion | ESP32 Full images without dual CDC start with framed Companion on their one TTY. `set usb.logging on` switches it to an input-capable plaintext logging terminal; `set usb.logging off` replies and then restores framed Companion automatically. BLE, WiFi, and source-only LoRa OTA remain available. | +| Dual-CDC Full Companion | nRF52 and qualified native-USB ESP32-S3 Full images use one physical USB connection. 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 interface `02` for plaintext logs. They also provide BLE and source-only LoRa OTA; ESP32 additionally provides WiFi. `get/set usb.logging` persistently controls whether the logging interface is present. | +| Single-TTY Full Companion | ESP32 Full images without dual CDC start with the ASCII terminal on their one TTY and automatically hand a complete `<` frame to framed Companion. `set usb.logging on` switches it to an input-capable plaintext logging terminal; `set usb.logging off` replies and then restores framed Companion automatically. 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. | | `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove 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 GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. | `logging`, `OTA`, and `FULL` describe independent build features in historical diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index b6220ead..c23bfb94 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -233,7 +233,7 @@ itself. | Platform | Interface | Purpose | | --- | --- | --- | -| Both | USB, 115200 baud | Binary Companion by default; terminal switch available | +| Both | USB, 115200 baud | ASCII after boot; automatically switches on the first complete Binary Companion frame | | Both | BLE | Binary Companion; display builds show a random session PIN, while headless builds default to `123456` | | ESP32 | TCP 5000 | Binary Companion over WiFi | | ESP32 | HTTP 80 | Companion WebConfig and first-boot WiFi setup | @@ -278,18 +278,29 @@ Expose them only on a trusted LAN or temporary setup network. See ## USB Binary and text terminal modes -USB starts in Binary mode for MeshCore apps and `meshcli`: +Full Companion USB starts in the ASCII terminal after boot. MeshCore apps and +`meshcli` send a `<`-prefixed framed command, which automatically hands the +untouched frame to the Binary Companion parser: ```bash meshcli -s /dev/ttyACM0 -b 115200 ver ``` -Open the port with the terminal start token sent automatically: +The automatic probe runs only at an empty prompt. A complete frame confirms +binary mode; an incomplete probe returns to ASCII after one second. Binary mode +then remains selected until reboot or the explicit terminal start token. See +[Full Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) +for the byte-level state machine, logging and mOTA ownership, recovery paths, +and known limitations. + +Immediately after boot, an ordinary terminal can issue ASCII commands without +a start token. If the device is already in Binary Companion mode, open the port +with the terminal start token sent automatically: ```bash picocom -b 115200 \ --imap spchex \ - --initstring '+++MESHCORE-TERM-START' \ + --initstring $'+++MESHCORE-TERM-START\r' \ /dev/ttyACM0 ``` @@ -310,8 +321,9 @@ logging off on a fresh installation. ### Single USB serial port On an ESP32 Full Companion without dual CDC, interface `00` has two exclusive -modes. It starts as framed Binary Companion. Enter its text terminal with -`+++MESHCORE-TERM-START`, then run `set usb.logging on`; the same TTY emits +modes. It starts as the ASCII terminal unless a saved logging-on preference +boots directly into the logging terminal. If it is already binary, enter its +text terminal with `+++MESHCORE-TERM-START`, then run `set usb.logging on`; the same TTY emits plaintext packet/debug logs and continues accepting CLI commands, including `set usb.logging off`. Turning it off sends the command reply and then returns that TTY to Binary Companion automatically, including on USB-UART bridges that @@ -324,8 +336,9 @@ available while USB is logging. Current nRF52 Full Companion and qualified native-USB ESP32-S3 Full Companion firmware can expose two CDC ACM serial interfaces on one physical USB cable: -- USB interface `00` is the normal Binary Companion, text terminal, and serial - mOTA source port. It is always present. +- USB interface `00` is the primary Companion, text-terminal, and serial-mOTA + source port. It is always present, starts in ASCII after boot, and + automatically hands a complete `<` frame to Binary Companion. - USB interface `02` is the optional write-only plaintext packet/debug logging port. Host input on this interface is ignored and cannot invoke firmware commands. @@ -352,6 +365,14 @@ depending on a particular COM number. The nRF52 bootloader temporarily exposes its normal DFU serial interface during an update. Qualified S3 boards temporarily expose the ESP32-S3 ROM USB-JTAG serial port during a wired flash. +Opening the logging port prints `MeshCore USB logging port` followed by its +portable identity, `USB CDC 1; interface 02; Linux stable suffix: -if02`. +`get usb.logging` reports the same endpoint. Firmware cannot print the exact +`/dev/ttyACM*` or `COM*` name because Linux, macOS, or Windows assigns that name +after USB enumeration; use the `*-if02` link on Linux to obtain the exact path. +For example, `readlink -f /dev/serial/by-id/*-if02` prints the host-assigned +`/dev/ttyACM*` name. + Dual-CDC ESP32-S3 targets are Heltec V4, T-Beam 1W, Station G2/G3, XIAO S3 WIO, Heltec Tracker V2, Meshnology W12, and Nibble Screen/Zero Connect. The base Heltec V4 profile has completed live two-interface, ROM-flashing, and @@ -374,9 +395,14 @@ service at interface `02`. ESP32 Full Companion exposes this same text terminal on TCP port 5002. Connect with `nc DEVICE_IP 5002`; no USB control token is needed. USB terminal mode and the TCP terminal share recipient, login, command, trace, and display state, so -only one may own the terminal at a time. Entering USB terminal mode closes an -active TCP terminal session. Disconnecting TCP clears pending terminal-only -state without cancelling Binary Companion delivery or radio retries. +only one may own the terminal at a time. The idle startup USB prompt yields to +a TCP connection when no USB data client or partial command is present, and is +restored when TCP disconnects. An active USB session rejects TCP; entering USB +terminal mode later closes an active TCP session. On USB-Serial-JTAG and +USB-to-UART hardware, the firmware cannot observe an idle host open, so actual +buffered USB activity—not the physical cable alone—claims ownership. +Disconnecting TCP clears pending terminal-only state without cancelling Binary +Companion delivery or radio retries. Both terminal transports accept `reboot`. The reply is sent first and the device reboots one second later, so a script can distinguish an accepted reboot @@ -453,27 +479,35 @@ Return to Binary mode with: +++MESHCORE-TERM-STOP ``` -Closing the USB data connection also resets the port to Binary mode. A -different baud rate, including 57600, does not select ASCII mode. +Closing an armed ASCII USB data connection also changes the port to Binary +mode when the hardware can report disconnect. A USB-to-UART bridge may not be +able to report this event. A different baud rate, including 57600, does not +select ASCII mode. On an ESP32 Full Companion built with `OTA_FOLDER_SERIAL`, `motatool` can keep -that terminal session open as an mOTA folder source when WiFi is unavailable: +the shared serial console open as an mOTA folder source when WiFi is +unavailable: ```sh -motatool serve --serial /dev/ttyACM0 --companion-terminal --dir ./motas -v +motatool serve --serial /dev/ttyACM0 --dir ./motas -v ``` -The explicit flag is required because an ESP32 Companion starts the same USB -port in binary Companion mode. TCP port 5001 remains the preferred unattended -source transport. +The tool sends `ota folder on` through the text CLI, then uses the shared +serial mOTA framing. This is separate from the nRF52 exclusive USB ownership +mode described below. TCP port 5001 remains the preferred unattended source +transport. ## nRF52 USB mOTA mode The nRF52 full target has a third, exclusive USB mode for the host folder. -Unmodified `motatool serve --serial` already sends `ota folder on` when it -opens the port. The Binary parser recognizes that exact idle control sequence, -stops USB Binary traffic, and attaches the serial folder source. The sequence -is not examined inside a framed Binary Companion packet. +Unmodified `motatool serve --serial` sends `ota folder on` when it opens the +port. The startup ASCII terminal recognizes that exact completed line, leaves +terminal mode, and gives the stream directly to exclusive mOTA handling. If +the port is already in Binary Companion mode, the idle binary parser recognizes +the same control sequence. The sequence is not examined inside a framed Binary +Companion packet. See the +[switcher guide](./full_companion_usb_switcher.md#logging-and-mota-ownership) +for the ownership transitions. While mOTA mode owns USB: @@ -483,7 +517,8 @@ While mOTA mode owns USB: - `motatool` sending `ota folder off`, or disconnecting the USB data session, detaches the folder and restores Binary mode. -No manual mode token or modified `motatool` build is required. +No modified `motatool` build, terminal token, or preliminary mode change is +required. ## nRF52 Bluetooth mOTA source @@ -562,7 +597,7 @@ and close the terminal: ```bash picocom -b 115200 \ --imap spchex \ - --initstring '+++MESHCORE-TERM-START' \ + --initstring $'+++MESHCORE-TERM-START\r' \ /dev/ttyACM1 ``` diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 4c3c8487..eda1f455 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -191,8 +191,10 @@ artifact is emitted. KISS, BLE-only Companion, and constrained LoRa-OTA receiver images retain their protocol/partition contracts and do not inherit plaintext USB logging. -Dual-CDC nRF52 and qualified native-USB ESP32-S3 builds keep Binary Companion -on interface `00`; `set usb.logging on reboot` adds plaintext interface `02`. +Dual-CDC nRF52 and qualified native-USB ESP32-S3 builds keep the multi-role +primary interface on `00`; it starts as an ASCII terminal and automatically +hands a complete `<` frame to Binary Companion. `set usb.logging on reboot` +adds plaintext interface `02`. Single-TTY ESP32 builds instead use `set usb.logging on` to switch that TTY to an input-capable plaintext logging terminal. `set usb.logging off` stops the logs and returns the TTY to Binary Companion after its reply. BLE and Wi-Fi @@ -246,6 +248,7 @@ For hardware with a dual-CDC Full Companion image, the picker recommends that one normal image instead of separate USB, BLE, ordinary WiFi, and USB-logging images. Full Companion provides the attached transports and a dedicated plaintext logging port when enabled without mixing logs into framed Companion -traffic. Logging is off by default, so only the framed port appears until it is -enabled and the node reboots. +traffic. Logging is off by default, so only primary interface `00` appears; it +starts in ASCII and automatically changes to framed Companion when a complete +`<` frame arrives. Enabling logging and rebooting adds interface `02`. Exact filename search still finds old aliases from earlier releases. diff --git a/docs/full_companion_usb_switcher.md b/docs/full_companion_usb_switcher.md new file mode 100644 index 00000000..36a86ee4 --- /dev/null +++ b/docs/full_companion_usb_switcher.md @@ -0,0 +1,251 @@ +# Full Companion USB CLI and binary switcher + +Full Companion uses one primary USB serial interface for two incompatible wire +formats: + +- a human-readable ASCII command line; +- the framed Binary Companion protocol used by MeshCore apps and `meshcli`. + +The primary interface starts in the ASCII terminal after each boot. A Binary +Companion client does not need to send a special mode command: its first valid +frame automatically hands the interface to the binary parser. + +This automatic behavior is compiled only into `companion_radio_full` targets. +Ordinary USB Companion builds continue to use the explicit +`+++MESHCORE-TERM-START` and `+++MESHCORE-TERM-STOP` controls described in the +[Terminal Chat CLI guide](./terminal_chat_cli.md). + +## Wire formats + +Host-to-device Binary Companion frames use this layout: + +```text +'<' length-low length-high payload[length] +``` + +Device-to-host frames use the same little-endian length with a different +marker: + +```text +'>' length-low length-high payload[length] +``` + +For example, a representative two-byte device query is: + +```text +3C 02 00 16 03 +``` + +The ASCII terminal is line-oriented and accepts commands such as: + +```text +get radio.cad +set display.rotation 90 +reboot +``` + +Changing the configured baud rate does not select a mode. Use 115200 for +compatibility even though native USB CDC hardware does not use UART timing. + +## State transitions + +```text + complete '<' frame + +--------------------------------+ + | v +boot ------> ASCII terminal Binary Companion + ^ | + | | +++MESHCORE-TERM-START + | incomplete '<' probe | + | (one-second timeout) | + +--------------------------------+ + +ASCII terminal -- +++MESHCORE-TERM-STOP ------> Binary Companion +ASCII terminal -- observable USB disconnect ---> Binary Companion +any mode ------- reboot ------------------------> ASCII terminal +``` + +Serial mOTA and single-TTY logging add exclusive ownership states described +below. BLE, WiFi, Ethernet, and hardware-serial Companion transports are not +switched; they remain binary. + +## How automatic detection works + +1. Full Companion initializes the normal USB Binary Companion interface, then + gives its primary stream to the ASCII terminal before normal loop service + begins. +2. While the prompt has no buffered input, the terminal peeks at the next byte. + It does not remove that byte. +3. If the byte is `<`, the terminal temporarily releases the stream and enables + the existing `ArduinoSerialInterface` frame parser. +4. The parser consumes the original `<`, the two-byte length, and the payload. + There is no second parser and no copied or synthetic frame. +5. A monotonically increasing completed-frame counter confirms that the parser + received a complete frame. The interface then remains in Binary Companion + mode. +6. If no complete frame arrives within one second, the parser state is reset + and the ASCII terminal prints a new banner and prompt. + +The switcher checks framing, not client identity. Any syntactically complete +Binary Companion frame confirms binary mode; it does not require the first +command to be `CMD_APP_START` or `CMD_DEVICE_QUERY`. Normal command validation +still occurs after the frame parser returns the payload. + +The empty-prompt requirement prevents a literal `<` in the middle of a command +from silently changing modes. A literal `<` typed as the first character does +start a probe, but the terminal returns after the one-second timeout if no +binary header and payload follow. + +## Manual controls + +The original controls remain available. + +From Binary Companion, send this exact unframed line while the parser is idle: + +```text ++++MESHCORE-TERM-START +``` + +Terminate it with CR or LF. The binary parser accepts control tokens only as +complete delimiter-bounded lines; prefixes, suffixes, and partial tokens are +ignored. + +From the ASCII terminal, send this exact sequence to return to binary mode: + +```text ++++MESHCORE-TERM-STOP +``` + +The stop sequence takes effect as soon as its last byte arrives in ASCII mode. +The start sequence is recognized only as a completed line while the binary +parser is idle and is not examined inside a length-prefixed frame. + +`meshcli` can normally connect directly after boot: + +```bash +meshcli -s /dev/ttyACM0 -b 115200 ver +``` + +An explicit terminal start token is still useful when the device is already in +binary mode: + +```bash +picocom -b 115200 \ + --imap spchex \ + --initstring $'+++MESHCORE-TERM-START\r' \ + /dev/ttyACM0 +``` + +## Logging and mOTA ownership + +The switcher never attempts to mix ASCII, framed Companion traffic, or binary +mOTA traffic on the same stream. + +| Situation | Primary USB behavior | +| --- | --- | +| Full Companion after boot | ASCII; a complete `<` frame switches to Binary Companion | +| Dual-CDC logging enabled | Primary interface still follows the switcher; logs use the optional second interface | +| Single-TTY logging enabled at boot | Logging terminal owns primary USB; automatic `<` detection is disabled | +| nRF52 USB serial mOTA active | mOTA owns primary USB; ASCII and Binary Companion are unavailable there | +| BLE/WiFi/Ethernet/hardware serial | Always Binary Companion and unaffected by the USB mode | + +On a single-TTY build, use `set usb.logging off` in its logging terminal before +trying to use primary USB with an app. BLE and WiFi Companion transports remain +available while primary USB is logging. + +On nRF52, serial `motatool` is also text-first. Its exact initial +`ota folder on` line is recognized in either startup ASCII or Binary Companion +mode. From ASCII, the firmware leaves terminal mode and directly enters +exclusive mOTA ownership; from binary, the idle frame parser recognizes the +same control sequence. The following mOTA request/reply frames therefore cannot +be consumed by the ASCII line editor. `motatool serve --serial` can be the +first client after boot and does not require a terminal token or disconnect +workaround. + +Only the exact completed line selects mOTA from ASCII. Extra arguments, +leading/trailing whitespace, or a partial line remain ordinary terminal input. + +## Shortcomings and edge cases + +This mechanism is deliberately small and deterministic, but it is not a full +protocol negotiation layer. + +### It is startup selection, not per-connection negotiation + +After the first complete binary frame, the device stays in Binary Companion +mode. Closing `meshcli` does not automatically restore ASCII. Use the terminal +start token or reboot when an ASCII prompt is needed again. + +Conversely, closing an ASCII terminal on native USB normally changes the port +to binary mode because the firmware can observe USB DTR/data disconnect. The +next client therefore sees binary mode, not a new ASCII session. A USB-to-UART +bridge often cannot report disconnect, so it can remain in ASCII until the stop +token or a reboot. + +### Detection works only at an empty prompt + +If part of an ASCII command is already buffered, an incoming `<` is treated as +ordinary terminal input. Clear or submit the line before starting a Binary +Companion client. Only one process should have the serial port open. + +### The first frame has a one-second deadline + +The complete marker, length, and payload must arrive within the probe window. +This is generous for local USB but may reject a heavily buffered serial proxy, +a debugger that pauses the MCU, or a tool that writes the header and body with +a long delay. A timed-out client can retry after the ASCII banner appears. + +### Framing confirmation is not authentication + +Any complete length-prefixed frame selects binary mode, even if its command is +unknown or malformed at the application layer. This is safe for stream +separation but means a random complete frame can leave the device in binary +mode until manually switched back. + +### The terminal banner is best effort + +The firmware enters ASCII mode during boot, often before a host opens the CDC +device. The banner may therefore be absent even though the terminal is ready; +send a newline or a harmless `get` command rather than treating a missing +banner as proof of binary mode. + +A host that remains connected across a reboot may receive ASCII banner and +prompt bytes before the first binary response. Binary clients should discard +leading bytes until a plausible `>` frame marker and length are found, reject +implausible lengths, and resynchronize. The ordinary open-after-boot path has +been tested with `meshcli`, but third-party clients that assume byte zero is +always `>` may fail. + +### Text and binary output cannot be interleaved + +Primary USB suppresses Binary Companion output while the terminal owns the +stream. Packet/debug logging must use its dedicated CDC interface or the +exclusive single-TTY logging mode. Writing diagnostic text directly to the +primary binary stream will corrupt clients regardless of the switcher. + +### A literal leading `<` briefly hides the prompt + +Typing `<` as the first terminal character begins a binary probe. With no +complete frame, the prompt returns after one second and the banner is printed +again. There is currently no escape syntax for entering a literal leading `<`; +prefix it with another character if it is needed as command text. + +## Troubleshooting + +If `meshcli` cannot connect: + +1. close every terminal or logging reader using the primary interface; +2. confirm that saved single-TTY logging is off; +3. reboot and let `meshcli` be the first process to open the data interface; +4. use the stable `/dev/serial/by-id/*-if00` path on Linux when available; +5. if a prompt appears after one second, the client's first frame was not + completed inside the probe window. + +If an ASCII terminal shows no banner, press Enter and issue a harmless query +such as `get radio.cad`. If binary bytes appear, send the exact terminal start +token or reboot. + +The switch policy is implemented in +[`UsbAsciiBinarySwitch.h`](../src/helpers/UsbAsciiBinarySwitch.h), with stream +ownership in [`main.cpp`](../examples/companion_radio/main.cpp) and framing in +[`ArduinoSerialInterface.cpp`](../src/helpers/ArduinoSerialInterface.cpp). diff --git a/docs/index.md b/docs/index.md index 88717c92..10fc2b8f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ Below are a few quick start guides. - [Companion Protocol](./companion_protocol.md) - [Companion Offline Message Queue](./companion_offline_queue.md) - [Full Companion: ESP32 and nRF52](./companion_radio_full.md) +- [Full Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) - [Packet Format](./packet_format.md) - [QR Codes](./qr_codes.md) diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index dca88df6..1e624af2 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -53,16 +53,18 @@ reopening the controller while `motatool` owns the source port. The script rejects an attempt to use the same port for both. The USB ASCII switch (`+++MESHCORE-TERM-START`) is the local control path, not -the mOTA data framing. On a dual-CDC full Companion, the script uses that mode -briefly for `ota status` and TempRadio commands. It then closes the CLI and -starts `motatool`, whose existing `ota folder on` preamble switches the same -USB interface `00` into exclusive mOTA mode. If USB logging was enabled and the -Full Companion rebooted, its separate interface `02` continues to provide -plaintext logging and is not a controller or source port. With default logging -off, interface `02` is not enumerated. -On a native-USB ESP32-S3 Full Companion with serial folder support, -`motatool --companion-terminal` keeps the ASCII session open -while the same link carries framed folder requests. BLE remains available. +the mOTA data framing. On an nRF52 dual-CDC Full Companion, the script uses +that mode briefly for `ota status` and TempRadio commands. It then closes the +CLI and starts `motatool`; the exact `ota folder on` preamble switches the same +USB interface `00` into exclusive mOTA mode from either startup ASCII or +Binary Companion mode. If USB logging was enabled and the Full Companion +rebooted, its separate interface `02` continues to provide plaintext logging +and is not a controller or source port. With default logging off, interface +`02` is not enumerated. BLE remains available. + +ESP32 Full Companion normally uses its dedicated TCP seeder on port 5001. +ESP32 builds that also retain serial folder support use the shared-console +mOTA framing rather than the nRF52 exclusive USB ownership state. ## Destination requirements @@ -157,7 +159,7 @@ automatically: ```bash picocom -b 115200 \ --imap spchex \ - --initstring '+++MESHCORE-TERM-START' \ + --initstring $'+++MESHCORE-TERM-START\r' \ /dev/ttyACM1 ``` @@ -409,6 +411,13 @@ An nRF52 `companion_radio_full` uses one USB source port sequentially. The runner automatically wraps local control commands in the terminal tokens, and unmodified `motatool` switches that port into mOTA mode when seeding starts: +The wrapper sends STOP, then START, before each command. This makes fallback +independent of whether a prior raw probe left an unobservable USB-UART +connection in ASCII or Binary mode. Seeder startup is reported only after the +verbose device log contains its `COUNT` acknowledgement; an immediate device +`ERR` or a missing acknowledgement fails during startup instead of surfacing +later as a catalog timeout. + ```bash ./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \ --controller-serial /dev/ttyACM0 \ diff --git a/docs/ota_easy.md b/docs/ota_easy.md index a4de0c8e..e7cbfa98 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -152,11 +152,15 @@ If an older build reports that `OTA_FOLDER_SERIAL` is not compiled in, install a `-full-usb-wifi-ota-`, or applicable `-full-logging-ota-` build first. Do **not** use a KISS modem: KISS firmware is a TNC/KISS frame interface and does not provide the MeshCore CLI or the OTA-folder transport that `motatool serve` requires. -An nRF52 `companion_radio_full` starts in USB Binary mode. Use -`+++MESHCORE-TERM-START` for local TempRadio commands, then return with -`+++MESHCORE-TERM-STOP`. When `motatool serve --serial` opens the port, its -automatic `ota folder on` command selects exclusive mOTA mode; stopping the -tool or disconnecting resets USB to Binary. BLE remains available throughout. +An nRF52 `companion_radio_full` starts in its USB ASCII terminal and +automatically changes to Binary Companion when it receives a complete `<` +frame. It also recognizes `motatool`'s exact initial `ota folder on` line and +enters exclusive mOTA mode directly, so `motatool serve --serial` can be the +first client after boot. No terminal token or preliminary disconnect is +required. Stopping the tool or disconnecting resets USB to Binary. BLE remains +available throughout. See the +[Full Companion USB switcher guide](./full_companion_usb_switcher.md#logging-and-mota-ownership) +for the complete ownership transitions. Protocol v14 can instead take the catalog from a paired phone or Linux host over a separate encrypted BLE mOTA service while Binary Companion remains active. USB and BLE catalog sources are mutually exclusive. See the diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index ae2c563e..acb9b148 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -226,22 +226,22 @@ relays are all v1.17.1.5 or newer, use the generic LoRa OTA runner; it can retain RXPS at the qualified level-8/preamble-64 boundary for SF5/BW250 or the level-8/preamble-128 boundary for SF5/BW500. -Put the source on the identical TempRadio tuple. If it is a binary-mode Full -Companion, the current `motatool` can switch modes for the serving session: +Put the source on the identical TempRadio tuple. A current ASCII-first Full +Companion recognizes `motatool`'s initial `ota folder on` line directly: ```bash motatool serve \ --dir ./motas \ --serial /dev/ttyACM1 \ --baud 115200 \ - --companion-terminal \ -v ``` -Omit `--companion-terminal` for an ordinary text-console OTA source. Restart -`motatool` for every step so the source emits a fresh catalog advert. Leave it -running during the download and stop it with Ctrl-C only after the destination -reports `ready to install`. +The same command also works with older Full Companion firmware in binary mode, +where the idle parser recognizes the identical preamble. Restart `motatool` for +every step so the source emits a fresh catalog advert. Leave it running during +the download and stop it with Ctrl-C only after the destination reports `ready +to install`. ### 3. Install all nine packages in order diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index dcddd6bf..dcc1a28f 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -4,23 +4,27 @@ Below are the commands you can enter into the Terminal Chat clients: ## Companion USB mode -A Companion USB build starts in the normal binary Companion protocol at -115200 baud. Use this command to switch the same USB connection into terminal +An ordinary Companion USB build starts in the normal binary Companion protocol +at 115200 baud. Use this command to switch the same USB connection into terminal mode as soon as `picocom` opens it: ```sh picocom --baud 115200 \ --imap spchex \ - --initstring '+++MESHCORE-TERM-START' \ + --initstring $'+++MESHCORE-TERM-START\r' \ /dev/ttyACM0 ``` -`--initstring` sends this exact terminal-start sequence automatically: +`--initstring` sends this exact terminal-start line automatically: ``` +++MESHCORE-TERM-START ``` +The carriage return is required in Binary mode. Control tokens are recognized +only as complete CR/LF-delimited lines, so the same text embedded in unrelated +unframed input cannot switch modes accidentally. + Binary Companion frames can contain terminal control bytes. The `spchex` input map renders those bytes as bracketed hexadecimal during the short transition instead of allowing them to change the local terminal's character set or @@ -47,6 +51,12 @@ devices really change the UART timing and receive corrupt data. Binary mode is the framed Companion API used by apps and `meshcli`; close the terminal before opening that port from an app. +Full Companion differs: its primary USB interface starts in ASCII after boot +and automatically switches when it sees a complete `<`-prefixed Companion +frame at an empty prompt. The explicit start/stop tokens remain available. See +[Full Companion USB CLI and binary switcher](./full_companion_usb_switcher.md) +for the state machine and limitations. + ## Commands ``` diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 386a5e87..b12b39fe 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -359,6 +359,25 @@ void DataStore::loadPrefsInt(const char *filename, CompanionNodePrefs& _prefs, d >= (int)sizeof(_prefs.display_rotation_degrees)) { file.read((uint8_t *)&_prefs.display_rotation_degrees, sizeof(_prefs.display_rotation_degrees)); + if (file.available() >= (int)sizeof(_prefs.cad_enabled)) { + file.read((uint8_t *)&_prefs.cad_enabled, + sizeof(_prefs.cad_enabled)); + if (file.available() + >= (int)sizeof(_prefs.cad_scan_timeout_ms)) { + file.read((uint8_t *)&_prefs.cad_scan_timeout_ms, + sizeof(_prefs.cad_scan_timeout_ms)); + if (file.available() + >= (int)sizeof(_prefs.cad_retry_delay_ms)) { + file.read((uint8_t *)&_prefs.cad_retry_delay_ms, + sizeof(_prefs.cad_retry_delay_ms)); + if (file.available() + >= (int)sizeof(_prefs.cad_max_duration_ms)) { + file.read((uint8_t *)&_prefs.cad_max_duration_ms, + sizeof(_prefs.cad_max_duration_ms)); + } + } + } + } } } } @@ -443,6 +462,17 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou (uint8_t *)&_prefs.display_rotation_degrees, sizeof(_prefs.display_rotation_degrees)) == sizeof(_prefs.display_rotation_degrees); + success = success && file.write((uint8_t *)&_prefs.cad_enabled, + sizeof(_prefs.cad_enabled)) == sizeof(_prefs.cad_enabled); + success = success && file.write((uint8_t *)&_prefs.cad_scan_timeout_ms, + sizeof(_prefs.cad_scan_timeout_ms)) + == sizeof(_prefs.cad_scan_timeout_ms); + success = success && file.write((uint8_t *)&_prefs.cad_retry_delay_ms, + sizeof(_prefs.cad_retry_delay_ms)) + == sizeof(_prefs.cad_retry_delay_ms); + success = success && file.write((uint8_t *)&_prefs.cad_max_duration_ms, + sizeof(_prefs.cad_max_duration_ms)) + == sizeof(_prefs.cad_max_duration_ms); #if defined(NRF52_PLATFORM) success = file.commit(success); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 43a45d18..0031b9d9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -6,6 +6,7 @@ #include #include "helpers/radiolib/RXPowerSaving.h" #include "helpers/radiolib/RxBoostedGainDefaults.h" +#include "helpers/radiolib/CadTiming.h" #if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) #include @@ -193,6 +194,10 @@ static bool save_filter(const ContactInfo& c); #ifndef DEFAULT_BUZZER_QUIET #define DEFAULT_BUZZER_QUIET 0 #endif +#ifndef DEFAULT_CAD_ENABLED +// Preserve the tuned Companion behavior that preceded the runtime setting. +#define DEFAULT_CAD_ENABLED 1 +#endif #ifndef EMERGENCY_CLIENT_REPEAT_HOLD_MS #define EMERGENCY_CLIENT_REPEAT_HOLD_MS 120000UL @@ -439,7 +444,19 @@ float MyMesh::getAirtimeBudgetFactor() const { } bool MyMesh::getCADEnabled() const { - return true; // tuned branch behavior: hardware CAD before every companion TX + return _prefs.cad_enabled != 0; +} + +uint32_t MyMesh::getCADFailRetryDelay() const { + return _prefs.cad_retry_delay_ms != 0 + ? _prefs.cad_retry_delay_ms + : BaseChatMesh::getCADFailRetryDelay(); +} + +uint32_t MyMesh::getCADFailMaxDuration() const { + return _prefs.cad_max_duration_ms != 0 + ? _prefs.cad_max_duration_ms + : mesh::Dispatcher::getCADFailMaxDuration(); } int MyMesh::getInterferenceThreshold() const { @@ -1477,6 +1494,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.wifi_enabled = 1; memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name)); _prefs.display_rotation_degrees = 0; + _prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0; + _prefs.cad_scan_timeout_ms = 0; + _prefs.cad_retry_delay_ms = 0; + _prefs.cad_max_duration_ms = 0; #if defined(ENABLE_USB_INTERFACE) // Keep a USB Companion's primary stream exclusively framed on a fresh // install. Full dual-CDC builds can add a diagnostics port; single-TTY @@ -1601,6 +1622,12 @@ void MyMesh::begin(bool has_display, bool radio_available) { _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.cad_enabled = constrain(_prefs.cad_enabled, 0, 1); + if (_prefs.cad_scan_timeout_ms != 0 + && (_prefs.cad_scan_timeout_ms < mesh::CAD_SCAN_MIN_TIMEOUT_MS + || _prefs.cad_scan_timeout_ms > mesh::CAD_SCAN_MAX_TIMEOUT_MS)) { + _prefs.cad_scan_timeout_ms = 0; + } _prefs.rx_ps_level = constrain(_prefs.rx_ps_level, 0, 10); if (_prefs.rx_ps_preamble != 16 && _prefs.rx_ps_preamble != 32) { _prefs.rx_ps_preamble = 0; @@ -1717,6 +1744,8 @@ void MyMesh::configureRadioFromPrefs() { } saved_radio_apply_pending = !applySavedRadioParams(); + radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms); + _radio->setCADEnabled(_prefs.cad_enabled != 0); if (!saved_radio_apply_pending) { radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1868,11 +1897,144 @@ void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) { } #endif +static bool parseCadTimingMillis(const char* text, uint32_t minimum, + uint32_t maximum, uint16_t& result) { + if (strcmp(text, "auto") == 0 || strcmp(text, "0") == 0) { + result = 0; + return true; + } + + char* end = NULL; + const unsigned long parsed = strtoul(text, &end, 10); + if (text[0] == 0 || end == NULL || *end != 0 + || parsed < minimum || parsed > maximum) { + return false; + } + result = (uint16_t)parsed; + return true; +} + +bool MyMesh::handleCadCommand(const char* command, char* reply, + size_t reply_size) { + if (command == NULL || reply == NULL || reply_size == 0) return false; + + if (strcmp(command, "get radio.cad") == 0 + || strcmp(command, "get cad") == 0) { + char scan[32]; + char retry[24]; + char maximum[24]; + if (_prefs.cad_scan_timeout_ms == 0) { + uint32_t effective = _radio_available + ? radio_driver.getCADScanTimeoutMillis() : 0; + if (effective == 0) { + effective = mesh::calculateCadScanTimeoutMillis(_prefs.sf, _prefs.bw); + } + snprintf(scan, sizeof(scan), "auto(%lu)", (unsigned long)effective); + } else { + snprintf(scan, sizeof(scan), "%u", (unsigned)_prefs.cad_scan_timeout_ms); + } + if (_prefs.cad_retry_delay_ms == 0) { + snprintf(retry, sizeof(retry), "auto"); + } else { + snprintf(retry, sizeof(retry), "%u", (unsigned)_prefs.cad_retry_delay_ms); + } + if (_prefs.cad_max_duration_ms == 0) { + snprintf(maximum, sizeof(maximum), "auto"); + } else { + snprintf(maximum, sizeof(maximum), "%u", (unsigned)_prefs.cad_max_duration_ms); + } + snprintf(reply, reply_size, + "radio.cad %s, scan=%s ms, retry=%s ms, max=%s ms", + _prefs.cad_enabled ? "on" : "off", scan, retry, maximum); + return true; + } + + const char* value = NULL; + if (strncmp(command, "set radio.cad", 13) == 0 + && (command[13] == 0 || command[13] == ' ' + || command[13] == '\t')) { + value = command + 13; + } else if (strncmp(command, "set cad", 7) == 0 + && (command[7] == 0 || command[7] == ' ' + || command[7] == '\t')) { + value = command + 7; + } else { + return false; + } + while (*value == ' ' || *value == '\t') value++; + + if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { + const uint8_t previous = _prefs.cad_enabled; + _prefs.cad_enabled = strcmp(value, "on") == 0 ? 1 : 0; + if (_radio_available) _radio->setCADEnabled(_prefs.cad_enabled != 0); + if (!savePrefs()) { + _prefs.cad_enabled = previous; + if (_radio_available) _radio->setCADEnabled(previous != 0); + snprintf(reply, reply_size, "Error: CAD changed but save failed"); + } else { + snprintf(reply, reply_size, "OK - radio.cad %s", value); + } + return true; + } + + if (strncmp(value, "timings", 7) == 0 + && (value[7] == 0 || value[7] == ' ' || value[7] == '\t')) { + value += 7; + while (*value == ' ' || *value == '\t') value++; + char scan_text[16]; + char retry_text[16]; + char max_text[16]; + char extra[2]; + uint16_t scan_ms; + uint16_t retry_ms; + uint16_t max_ms; + const int fields = sscanf(value, "%15s %15s %15s %1s", + scan_text, retry_text, max_text, extra); + if (fields != 3 + || !parseCadTimingMillis(scan_text, + mesh::CAD_SCAN_MIN_TIMEOUT_MS, + mesh::CAD_SCAN_MAX_TIMEOUT_MS, scan_ms) + || !parseCadTimingMillis(retry_text, 1, 60000, retry_ms) + || !parseCadTimingMillis(max_text, 1, 60000, max_ms)) { + snprintf(reply, reply_size, + "Error: use set radio.cad timings "); + return true; + } + + const uint16_t previous_scan = _prefs.cad_scan_timeout_ms; + const uint16_t previous_retry = _prefs.cad_retry_delay_ms; + const uint16_t previous_max = _prefs.cad_max_duration_ms; + if (_radio_available && !radio_driver.setCADScanTimeoutMillis(scan_ms)) { + snprintf(reply, reply_size, "Error: CAD scan timing is unsupported"); + return true; + } + _prefs.cad_scan_timeout_ms = scan_ms; + _prefs.cad_retry_delay_ms = retry_ms; + _prefs.cad_max_duration_ms = max_ms; + if (!savePrefs()) { + _prefs.cad_scan_timeout_ms = previous_scan; + _prefs.cad_retry_delay_ms = previous_retry; + _prefs.cad_max_duration_ms = previous_max; + if (_radio_available) radio_driver.setCADScanTimeoutMillis(previous_scan); + snprintf(reply, reply_size, "Error: CAD timings changed but save failed"); + } else { + snprintf(reply, reply_size, "OK - radio.cad timings saved"); + } + return true; + } + + snprintf(reply, reply_size, + "Error: use set radio.cad or set radio.cad timings "); + return true; +} + bool MyMesh::handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; + if (handleCadCommand(command, reply, reply_size)) return true; + if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: display rotation is unsupported"); @@ -2453,6 +2615,7 @@ void MyMesh::onConfigBatchEnd() { void MyMesh::execCommand(char* cmd, char* reply) { reply[0] = 0; + if (handleCadCommand(cmd, reply, 160)) return; if (cmd && (strcmp(cmd, "get bluetooth.name") == 0 || strcmp(cmd, "get ble.name") == 0)) { formatBluetoothNameStatus(reply, 160); @@ -5311,8 +5474,9 @@ void MyMesh::handleTerminalCommand(char* command) { #if MESH_USB_LOGGING_AVAILABLE } else if (strcmp(command, "get usb.logging") == 0) { terminalOutput().printf( - " usb.logging %s%s\r\n", + " usb.logging %s; port: %s%s\r\n", mesh::isUsbLoggingEnabled() ? "on" : "off", + mesh::usbLoggingPortDescription(), mesh::usbLoggingInterfaceRestartRequired() ? " (reboot required to change USB interfaces)" : ""); #endif @@ -5584,6 +5748,9 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" get radio.rxps\r\n"); terminalOutput().print(" get radio.rxps.config\r\n"); terminalOutput().print(" set radio.rxps \r\n"); + terminalOutput().print(" get radio.cad\r\n"); + terminalOutput().print(" set radio.cad \r\n"); + terminalOutput().print(" set radio.cad timings \r\n"); terminalOutput().print(" get radio.rxgain\r\n"); terminalOutput().print(" set radio.rxgain \r\n"); terminalOutput().print(" get radio.fem.rxgain\r\n"); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 456ddc8e..c626f5b1 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -198,6 +198,8 @@ protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; bool getCADEnabled() const override; + uint32_t getCADFailRetryDelay() const override; + uint32_t getCADFailMaxDuration() const override; #ifdef WITH_MQTT_BRIDGE uint32_t getRadioWatchdogMillis() const override { return 0; } #endif @@ -315,6 +317,7 @@ private: bool applyAndSaveFemRxGain(bool enabled); bool applyAndSaveFemTxGain(bool enabled); bool applyAndSaveRxBoostedGain(bool enabled); + bool handleCadCommand(const char* command, char* reply, size_t reply_size); bool saveBluetoothNameOverride(const char* name); bool applyAndSaveBluetoothName(const char* value, char* reply, size_t reply_size); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index aca64e31..91c706c0 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -58,6 +58,10 @@ struct CompanionNodePrefs { // persisted to file char bluetooth_name[mesh::companion::BLUETOOTH_NAME_SIZE]; // exact BLE name; empty uses BLE_NAME_PREFIX + node_name uint16_t display_rotation_degrees; // 0=board default; otherwise 90/180/270 + uint8_t cad_enabled; // hardware channel activity detection + uint16_t cad_scan_timeout_ms; // 0=derive from SF/BW + uint16_t cad_retry_delay_ms; // 0=Mesh adaptive default + uint16_t cad_max_duration_ms; // 0=Dispatcher default // Keep the upstream repeat API while retaining the existing binary prefs // layout used by this branch. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f6165ecd..06196e44 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -81,12 +81,13 @@ MultiSerialInterface interface_manager; #if defined(ENABLE_USB_INTERFACE) #include #include + #include static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START"; static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP"; #if COMPANION_FEATURE_USB_MOTA_SOURCE // motatool sends this command automatically when `serve --serial` opens the - // port. In Binary mode it is an exact, idle-parser control sequence that - // hands USB ownership to the host-backed mOTA source. + // port. It hands USB ownership to the host-backed mOTA source from either + // the completed ASCII line or Binary mode's idle control-sequence parser. static const char USB_MOTA_START_TOKEN[] = "ota folder on"; #endif ArduinoSerialInterface usb_serial_interface; @@ -405,6 +406,9 @@ static size_t usb_terminal_line_len = 0; static bool usb_terminal_discard_line = false; static bool usb_terminal_disconnect_armed = false; static bool usb_logging_terminal_mode = false; +#if defined(COMPANION_RADIO_FULL) +static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; +#endif #if COMPANION_FEATURE_USB_MOTA_SOURCE static bool usb_mota_mode = false; static char usb_mota_line[32]; @@ -453,7 +457,22 @@ static bool isUsbTerminalDataConnected() { #endif } +static bool hasObservableActiveUsbTerminalClient() { +#if 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 + // idle physical cable must not permanently lock TCP port 5002. + return usb_terminal_line_len != 0 || usb_terminal_discard_line; +#else + return isUsbTerminalDataConnected(); +#endif +} + static void enterUsbTerminalMode() { +#if defined(COMPANION_RADIO_FULL) + usb_binary_startup_probe.cancel(); +#endif the_mesh.cancelSerialResponseStream(); usb_serial_interface.setPassthroughMode(true); clearUsbTerminalLine(); @@ -500,7 +519,7 @@ static void leaveUsbMotaMode(bool acknowledge) { resetUsbMotaMode(); } -static void enterUsbMotaMode() { +static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) { the_mesh.cancelSerialResponseStream(); usb_serial_interface.setPassthroughMode(true); usb_mota_mode = true; @@ -515,11 +534,15 @@ static void enterUsbMotaMode() { Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode"); Serial.print("\r\n"); resetUsbMotaMode(); - return; + if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) { + enterUsbTerminalMode(); + } + return false; } Serial.print("\r\n"); Serial.print(reply); Serial.print("\r\n"); + return true; } static void serviceUsbMota() { @@ -588,11 +611,22 @@ static void serviceUsbTerminal() { } #endif if (!the_mesh.isTerminalMode()) { +#if defined(COMPANION_RADIO_FULL) + const mesh::UsbBinaryStartupProbe::Result probe_result = + usb_binary_startup_probe.poll( + millis(), usb_serial_interface.getCompletedFrameCount(), + usb_serial_interface.getLastFrameMillis()); + if (probe_result + == mesh::UsbBinaryStartupProbe::Result::RETURN_TO_ASCII) { + enterUsbTerminalMode(); + return; + } +#endif if (usb_serial_interface.takeControlSequence()) { enterUsbTerminalMode(); #if COMPANION_FEATURE_USB_MOTA_SOURCE } else if (usb_serial_interface.takeSecondaryControlSequence()) { - enterUsbMotaMode(); + enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY); #endif } return; @@ -605,6 +639,21 @@ static void serviceUsbTerminal() { return; } +#if defined(COMPANION_RADIO_FULL) + // Full Companion boots as a useful ASCII terminal. MeshCLI's first framed + // command begins with '<'; hand that byte over untouched at an empty prompt. + // A malformed or accidental probe times out and restores the terminal. + if (!usb_logging_terminal_mode + && usb_binary_startup_probe.shouldStart( + usb_terminal_line_len == 0, usb_terminal_discard_line, + Serial.peek())) { + const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount(); + leaveUsbTerminalMode(false); + usb_binary_startup_probe.start(millis(), frame_count); + return; + } +#endif + while (Serial.available()) { int value = Serial.read(); if (value < 0) break; @@ -630,6 +679,18 @@ static void serviceUsbTerminal() { if (c == '\r' || c == '\n') { if (usb_terminal_line_len == 0) continue; Serial.print("\r\n"); +#if COMPANION_FEATURE_USB_MOTA_SOURCE + // motatool is deliberately text-first: `serve --serial` opens the port + // and sends this command before its binary mOTA request/reply traffic. + // Full Companion now boots in ASCII, so transfer ownership directly + // instead of letting the ordinary terminal command handler leave the + // stream in line-oriented mode. + if (strcmp(usb_terminal_line, USB_MOTA_START_TOKEN) == 0) { + leaveUsbTerminalMode(false); + enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII); + return; + } +#endif the_mesh.handleTerminalCommand(usb_terminal_line); clearUsbTerminalLine(); #if MESH_USB_LOGGING_AVAILABLE @@ -661,6 +722,22 @@ static void serviceUsbTerminal() { } } } + +#if defined(COMPANION_RADIO_FULL) +static void expireUsbBinaryStartupProbeBeforeDispatch() { + const uint32_t now = millis(); + if (!usb_binary_startup_probe.hasTimedOut(now)) return; + + // The dispatcher normally consumes Binary Companion input before the ASCII + // terminal service runs. Enforce the advertised deadline here so bytes that + // are still incomplete at one second cannot complete a frame afterwards. + // Entering passthrough resets the partial binary parser; drain only bytes + // already queued for that expired attempt so they cannot begin a new probe. + int pending = Serial.available(); + while (pending-- > 0) Serial.read(); + enterUsbTerminalMode(); +} +#endif #endif void halt() { @@ -886,6 +963,9 @@ void halt() { static char ota_console_line[MAX_TRANS_UNIT * 2 + 32]; static size_t ota_console_len = 0; static bool ota_console_discard_line = false; +#if COMPANION_FEATURE_NETWORK_TERMINAL && defined(ENABLE_USB_INTERFACE) + static mesh::UsbTcpTerminalHandoff ota_console_usb_handoff; +#endif static void ota_console_clear_line() { memset(ota_console_line, 0, sizeof(ota_console_line)); @@ -903,9 +983,21 @@ void halt() { #endif } +#if COMPANION_FEATURE_NETWORK_TERMINAL + static void ota_console_release_terminal() { + the_mesh.exitNetworkTerminalMode(ota_console_client); +#if defined(ENABLE_USB_INTERFACE) + if (ota_console_usb_handoff.shouldRestoreAscii( + usb_serial_interface.getCompletedFrameCount())) { + enterUsbTerminalMode(); + } +#endif + } +#endif + static void ota_console_stop() { #if COMPANION_FEATURE_NETWORK_TERMINAL - the_mesh.exitNetworkTerminalMode(ota_console_client); + ota_console_release_terminal(); #endif if (ota_console_client) ota_console_client.stop(); ota_console_server.end(); @@ -916,7 +1008,7 @@ void halt() { static void ota_console_loop() { if (!ota_console_client || !ota_console_client.connected()) { #if COMPANION_FEATURE_NETWORK_TERMINAL - the_mesh.exitNetworkTerminalMode(ota_console_client); + ota_console_release_terminal(); #endif WiFiClient c = ota_console_server.available(); if (c) { @@ -924,9 +1016,31 @@ void halt() { ota_console_clear_line(); ota_console_discard_line = false; #if COMPANION_FEATURE_NETWORK_TERMINAL - if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) { +#if defined(ENABLE_USB_INTERFACE) + const bool usb_ascii_selected = the_mesh.isTerminalMode(); + const bool usb_input_idle = usb_terminal_line_len == 0 + && !usb_terminal_discard_line + && !usb_binary_startup_probe.isActive(); + if (!ota_console_usb_handoff.begin( + usb_ascii_selected, hasObservableActiveUsbTerminalClient(), + usb_input_idle, + usb_serial_interface.getCompletedFrameCount())) { ota_console_client.print( - "ERROR: USB currently owns the Full Companion terminal\r\n"); + "ERROR: active USB currently owns the Full Companion terminal\r\n"); + ota_console_client.stop(); + return; + } + if (usb_ascii_selected) leaveUsbTerminalMode(false); +#endif + if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) { +#if defined(ENABLE_USB_INTERFACE) + if (ota_console_usb_handoff.shouldRestoreAscii( + usb_serial_interface.getCompletedFrameCount())) { + enterUsbTerminalMode(); + } +#endif + ota_console_client.print( + "ERROR: another client currently owns the Full Companion terminal\r\n"); ota_console_client.stop(); } #else @@ -937,6 +1051,9 @@ void halt() { } #if COMPANION_FEATURE_NETWORK_TERMINAL if (!the_mesh.isNetworkTerminalMode(ota_console_client)) { +#if defined(ENABLE_USB_INTERFACE) + ota_console_usb_handoff.cancel(); +#endif ota_console_client.print( "\r\nERROR: terminal ownership moved to USB; closing\r\n"); ota_console_client.stop(); @@ -960,7 +1077,7 @@ void halt() { #if COMPANION_FEATURE_NETWORK_TERMINAL if (strcmp(ota_console_line, "disconnect") == 0) { ota_console_client.print(" OK - disconnecting\r\n"); - the_mesh.exitNetworkTerminalMode(ota_console_client); + ota_console_release_terminal(); ota_console_client.stop(); ota_console_clear_line(); ota_console_discard_line = false; @@ -1401,6 +1518,13 @@ void setup() { enterUsbLoggingTerminalMode(); } #endif +#if defined(COMPANION_RADIO_FULL) + if (!the_mesh.isTerminalMode()) { + // Full Companion's primary USB port is an ASCII CLI until a Companion + // client presents a valid binary frame. BLE/WiFi/Ethernet stay binary. + enterUsbTerminalMode(); + } +#endif #endif // add ethernet interface @@ -1449,6 +1573,12 @@ void setup() { void loop() { #if defined(NRF52_PLATFORM) board.feedWatchdog(); +#endif + // Identify CDC 1 when a terminal opens it. Doing this on the connection edge + // avoids losing the marker during boot before the host has opened the port. + mesh::serviceUsbLoggingPort(); +#if defined(ENABLE_USB_INTERFACE) && defined(COMPANION_RADIO_FULL) + expireUsbBinaryStartupProbeBeforeDispatch(); #endif the_mesh.loop(); #ifdef RECOVERABLE_EXTERNAL_RADIO diff --git a/scripts/meshcore-terminal-chat.cmd b/scripts/meshcore-terminal-chat.cmd index 52507389..f0705d8c 100644 --- a/scripts/meshcore-terminal-chat.cmd +++ b/scripts/meshcore-terminal-chat.cmd @@ -177,7 +177,7 @@ function Enter-CompanionTerminalMode { param([Parameter(Mandatory)][System.IO.Ports.SerialPort]$Port) try { $Port.DiscardInBuffer() } catch { } - $Port.Write($script:StartToken) + $Port.Write($script:StartToken + "`r") # Do not render Binary Companion frames as console control characters while # the device changes modes. Start displaying once the text banner arrives. @@ -367,7 +367,7 @@ function Start-TerminalSession { for ($index = 0; $index -lt $lineCharacterCount; $index++) { $port.Write([string][char]8) } - $port.Write($script:StopToken) + $port.Write($script:StopToken + "`r") Start-Sleep -Milliseconds 150 } catch { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index eb279304..34baa831 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -107,6 +107,11 @@ public: virtual void setCADEnabled(bool enable) { } + // LoRa wrappers shadow these with their configurable CAD timeout. Other + // transports (such as ESP-NOW) support only the automatic/default value. + bool setCADScanTimeoutMillis(uint32_t timeout_ms) { return timeout_ms == 0; } + uint32_t getCADScanTimeoutMillis() const { return 0; } + virtual void resetAGC() { } virtual uint8_t getRadioState() const { return 0; } diff --git a/src/helpers/ArduinoSerialInterface.cpp b/src/helpers/ArduinoSerialInterface.cpp index f06164eb..e0899e2c 100644 --- a/src/helpers/ArduinoSerialInterface.cpp +++ b/src/helpers/ArduinoSerialInterface.cpp @@ -6,10 +6,18 @@ #define RECV_STATE_LEN1_FOUND 2 #define RECV_STATE_LEN2_FOUND 3 -void ArduinoSerialInterface::resetReceiveState() { - _state = RECV_STATE_IDLE; +void ArduinoSerialInterface::resetControlSequenceState() { _controlSequencePos = 0; _secondaryControlSequencePos = 0; + _controlSequenceCandidate = + _controlSequence != nullptr && _controlSequence[0] != 0; + _secondaryControlSequenceCandidate = + _secondaryControlSequence != nullptr && _secondaryControlSequence[0] != 0; +} + +void ArduinoSerialInterface::resetReceiveState() { + _state = RECV_STATE_IDLE; + resetControlSequenceState(); _frame_len = 0; rx_len = 0; _last_rx_byte_ms = 0; @@ -111,23 +119,33 @@ void ArduinoSerialInterface::serviceTransmit() { } } -bool ArduinoSerialInterface::checkControlSequence(uint8_t c, - const char* sequence, - size_t& position, - bool& received) { - if (sequence == nullptr || sequence[0] == 0) return false; +bool ArduinoSerialInterface::checkControlLineByte(uint8_t c) { + if (c == '\r' || c == '\n') { + const bool primary = _controlSequenceCandidate + && _controlSequence[_controlSequencePos] == 0; + const bool secondary = _secondaryControlSequenceCandidate + && _secondaryControlSequence[_secondaryControlSequencePos] == 0; + resetControlSequenceState(); + if (primary) _controlSequenceReceived = true; + if (secondary) _secondaryControlSequenceReceived = true; + return primary || secondary; + } - if (c == (uint8_t)sequence[position]) { - position++; - if (sequence[position] == 0) { - position = 0; - received = true; - return true; + if (_controlSequenceCandidate) { + if (_controlSequence[_controlSequencePos] != 0 + && c == (uint8_t)_controlSequence[_controlSequencePos]) { + ++_controlSequencePos; + } else { + _controlSequenceCandidate = false; + } + } + if (_secondaryControlSequenceCandidate) { + if (_secondaryControlSequence[_secondaryControlSequencePos] != 0 + && c == (uint8_t)_secondaryControlSequence[_secondaryControlSequencePos]) { + ++_secondaryControlSequencePos; + } else { + _secondaryControlSequenceCandidate = false; } - } else { - // Preserve a possible new match when this byte is also the first byte of - // the sequence (notably useful for sequences beginning with "+++"). - position = c == (uint8_t)sequence[0] ? 1 : 0; } return false; } @@ -235,11 +253,7 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) { switch (_state) { case RECV_STATE_IDLE: - if (checkControlSequence((uint8_t)c, _controlSequence, - _controlSequencePos, _controlSequenceReceived) - || checkControlSequence((uint8_t)c, _secondaryControlSequence, - _secondaryControlSequencePos, - _secondaryControlSequenceReceived)) { + if (checkControlLineByte((uint8_t)c)) { // Leave any following bytes buffered for the passthrough consumer. return 0; } @@ -255,6 +269,7 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) { _frame_len |= ((uint16_t)c) << 8; // MSB rx_len = 0; _state = _frame_len > 0 ? RECV_STATE_LEN2_FOUND : RECV_STATE_IDLE; + if (_state == RECV_STATE_IDLE) resetControlSequenceState(); break; default: if (rx_len < MAX_FRAME_SIZE) { @@ -265,7 +280,9 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) { if (_frame_len > MAX_FRAME_SIZE) _frame_len = MAX_FRAME_SIZE; // truncate memcpy(dest, rx_buf, _frame_len); _state = RECV_STATE_IDLE; // reset state, for next frame + resetControlSequenceState(); _last_frame_ms = millis(); // a real client is talking to us + ++_completed_frame_count; _has_received_frame = true; return _frame_len; } diff --git a/src/helpers/ArduinoSerialInterface.h b/src/helpers/ArduinoSerialInterface.h index c5652e7e..769b8228 100644 --- a/src/helpers/ArduinoSerialInterface.h +++ b/src/helpers/ArduinoSerialInterface.h @@ -25,9 +25,12 @@ private: uint8_t _state; size_t _controlSequencePos; size_t _secondaryControlSequencePos; + bool _controlSequenceCandidate; + bool _secondaryControlSequenceCandidate; uint16_t _frame_len; uint16_t rx_len; uint32_t _last_frame_ms; + uint32_t _completed_frame_count; uint32_t _last_rx_byte_ms; bool _has_received_frame; Stream* _serial; @@ -39,8 +42,8 @@ private: uint8_t _tx_queue_len; uint16_t _tx_offset; - bool checkControlSequence(uint8_t c, const char* sequence, - size_t& position, bool& received); + void resetControlSequenceState(); + bool checkControlLineByte(uint8_t c); void resetReceiveState(); void serviceReceiveTimeout(); void resetTransmitState(); @@ -52,8 +55,10 @@ public: : _isEnabled(false), _passthroughMode(false), _controlSequenceReceived(false), _secondaryControlSequenceReceived(false), _flow_ctl(false), _state(0), _controlSequencePos(0), - _secondaryControlSequencePos(0), _frame_len(0), rx_len(0), - _last_frame_ms(0), _last_rx_byte_ms(0), _has_received_frame(false), + _secondaryControlSequencePos(0), _controlSequenceCandidate(false), + _secondaryControlSequenceCandidate(false), _frame_len(0), rx_len(0), + _last_frame_ms(0), _completed_frame_count(0), _last_rx_byte_ms(0), + _has_received_frame(false), _serial(nullptr), _controlSequence(nullptr), _secondaryControlSequence(nullptr), _conn_check(nullptr), _tx_queue_len(0), _tx_offset(0) {} @@ -67,6 +72,7 @@ public: _controlSequenceReceived = false; _secondaryControlSequenceReceived = false; _last_frame_ms = 0; + _completed_frame_count = 0; _has_received_frame = false; resetReceiveState(); resetTransmitState(); @@ -93,6 +99,7 @@ public: // Useful as an activity-based connection check where no DTR state exists. uint32_t getLastFrameMillis() const { return _last_frame_ms; } bool hasReceivedFrame() const { return _has_received_frame; } + uint32_t getCompletedFrameCount() const { return _completed_frame_count; } // Optional: queue complete frames and drain one at a time according to the // stream's TX capacity, so short writes cannot discard or interleave bytes. diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index adff147f..12673358 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -29,6 +29,34 @@ static bool is_value_char(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.'; } +// ConfigSerializer writes floating-point preferences as fixed-point decimal +// text, and its tokenizer intentionally accepts neither exponent notation nor +// a leading '+'. Parsing that small grammar locally avoids linking Newlib's +// general-purpose strtod implementation (and its locale/bignum support) into +// flash-constrained firmware. +static double parse_fixed_decimal(const char* text) { + bool negative = false; + if (*text == '-') { + negative = true; + ++text; + } + + double value = 0.0; + while (*text >= '0' && *text <= '9') { + value = value * 10.0 + (*text++ - '0'); + } + + if (*text == '.') { + double place = 0.1; + while (*++text >= '0' && *text <= '9') { + value += (*text - '0') * place; + place *= 0.1; + } + } + + return negative ? -value : value; +} + #define EXPECT_OPEN_BRACE 0 #define EXPECT_KEY 1 #define EXPECT_VAL_OR_OBJ 2 @@ -290,7 +318,7 @@ void ConfigSerializer::def(const char* key, double& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = atof(_context->getToken()); + value = parse_fixed_decimal(_context->getToken()); } } } @@ -307,7 +335,7 @@ void ConfigSerializer::def(const char* key, float& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = (float) atof(_context->getToken()); + value = (float) parse_fixed_decimal(_context->getToken()); } } } diff --git a/src/helpers/UsbAsciiBinarySwitch.h b/src/helpers/UsbAsciiBinarySwitch.h new file mode 100644 index 00000000..f4ee9625 --- /dev/null +++ b/src/helpers/UsbAsciiBinarySwitch.h @@ -0,0 +1,98 @@ +#pragma once + +#include + +namespace mesh { + +// Coordinates the Full Companion USB startup handoff without consuming the +// '<' byte. ArduinoSerialInterface remains the only binary protocol parser. +class UsbBinaryStartupProbe { +public: + enum class Result : uint8_t { + INACTIVE, + WAITING, + BINARY_CONFIRMED, + RETURN_TO_ASCII, + }; + + static constexpr uint32_t TIMEOUT_MS = 1000; + +private: + bool _active = false; + uint32_t _started_at = 0; + uint32_t _frame_count_at_start = 0; + +public: + bool shouldStart(bool line_empty, bool discarding_line, + int next_byte) const { + return !_active && line_empty && !discarding_line && next_byte == '<'; + } + + void start(uint32_t now, uint32_t completed_frame_count) { + _active = true; + _started_at = now; + _frame_count_at_start = completed_frame_count; + } + + void cancel() { _active = false; } + bool isActive() const { return _active; } + + bool hasTimedOut(uint32_t now) const { + return _active && (uint32_t)(now - _started_at) >= TIMEOUT_MS; + } + + Result poll(uint32_t now, uint32_t completed_frame_count, + uint32_t completed_frame_at = 0) { + if (!_active) return Result::INACTIVE; + if (completed_frame_count != _frame_count_at_start) { + _active = false; + // A frame parsed before the deadline remains valid even if other mesh + // work delays this poll. A frame completed at/after the boundary loses to + // the timeout, which keeps the advertised one-second window strict. + return (uint32_t)(completed_frame_at - _started_at) < TIMEOUT_MS + ? Result::BINARY_CONFIRMED : Result::RETURN_TO_ASCII; + } + if (hasTimedOut(now)) { + _active = false; + return Result::RETURN_TO_ASCII; + } + return Result::WAITING; + } +}; + +// Tracks the narrow case where TCP temporarily borrows an idle startup ASCII +// terminal. A complete USB Binary frame during the TCP session wins ownership, +// so closing TCP must not force that USB client back to ASCII. +class UsbTcpTerminalHandoff { + bool _restore_ascii = false; + uint32_t _frame_count_at_start = 0; + +public: + bool begin(bool usb_ascii_selected, bool usb_data_connected, + bool usb_input_idle, uint32_t completed_frame_count) { + _restore_ascii = false; + if (!usb_ascii_selected) return true; + if (usb_data_connected || !usb_input_idle) return false; + _restore_ascii = true; + _frame_count_at_start = completed_frame_count; + return true; + } + + bool shouldRestoreAscii(uint32_t completed_frame_count) { + const bool restore = _restore_ascii + && completed_frame_count == _frame_count_at_start; + _restore_ascii = false; + return restore; + } + + void cancel() { _restore_ascii = false; } + bool isBorrowingAscii() const { return _restore_ascii; } +}; + +enum class UsbMotaEntryOrigin : uint8_t { BINARY, ASCII }; + +inline bool shouldRestoreAsciiAfterMotaFailure(UsbMotaEntryOrigin origin) { + return origin == UsbMotaEntryOrigin::ASCII; +} + +} // namespace mesh diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 2f2c147e..6935c533 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -59,6 +59,7 @@ static NullUsbLoggingStream null_usb_logging_stream; #if defined(MESH_DUAL_CDC_LOGGING) static bool dedicated_usb_logging_port_configured = false; static bool dedicated_usb_logging_port_started = false; +static bool dedicated_usb_logging_port_connected = false; #if defined(MESH_NRF52_DUAL_CDC_LOGGING) static Adafruit_USBD_CDC dedicated_usb_logging_port; #elif defined(MESH_ESP32_DUAL_CDC_LOGGING) @@ -162,6 +163,9 @@ void beginUsbLoggingPort() { dedicated_usb_logging_port->begin(115200); #elif defined(MESH_NRF52_DUAL_CDC_LOGGING) dedicated_usb_logging_port.begin(115200); + // `begin()` installs the core's generic "TinyUSB Serial" name. Replace it + // before (re-)enumeration so host USB tools can distinguish the log endpoint. + dedicated_usb_logging_port.setStringDescriptor("MeshCore Logging"); dedicated_usb_logging_port_configured = true; #endif dedicated_usb_logging_port_started = true; @@ -180,6 +184,28 @@ void beginUsbLoggingPort() { #endif } +void serviceUsbLoggingPort() { +#if defined(MESH_DUAL_CDC_LOGGING) + bool connected = false; +#if defined(MESH_NRF52_DUAL_CDC_LOGGING) + connected = dedicated_usb_logging_port_started + && dedicated_usb_logging_port.dtr(); +#elif defined(MESH_ESP32_DUAL_CDC_LOGGING) + connected = dedicated_usb_logging_port_started + && dedicated_usb_logging_port != nullptr + && (bool)*dedicated_usb_logging_port; +#endif + + if (connected && !dedicated_usb_logging_port_connected + && isUsbLoggingEnabled()) { + Stream& port = usbLoggingPort(); + port.println("MeshCore USB logging port"); + port.println("USB CDC 1; interface 02; Linux stable suffix: -if02"); + } + dedicated_usb_logging_port_connected = connected; +#endif +} + Stream& usbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) #if defined(MESH_NRF52_DUAL_CDC_LOGGING) @@ -223,5 +249,13 @@ bool usbLoggingInterfaceRestartRequired() { #endif } +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)"; +#endif +} + } // namespace mesh #endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index cba953c7..35b8cdac 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -29,10 +29,15 @@ bool saveUsbLoggingBootPreference(bool enabled); // Start the optional dedicated USB logging interface. Ordinary and single-TTY // builds use Serial; dual-CDC Full Companion builds use a second CDC ACM port. void beginUsbLoggingPort(); +// Emit a short identity marker whenever a host opens the dedicated logging +// endpoint. The device can report its USB interface, but the host alone chooses +// names such as /dev/ttyACM1 or COM7. +void serviceUsbLoggingPort(); Stream& usbLoggingPort(); bool hasDedicatedUsbLoggingPort(); bool isDedicatedUsbLoggingPortConfigured(); bool usbLoggingInterfaceRestartRequired(); +const char* usbLoggingPortDescription(); } // namespace mesh #endif diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 46889176..5ef909d0 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -153,7 +153,7 @@ protected: break; } scan_result = performChannelScanWithTimeout( - mesh::calculateCadScanTimeoutMillis(scan_sfs[i], bw)); + cadScanTimeoutMillis(scan_sfs[i], bw)); // Each individual CAD can finish inside the helper's one-second service // cadence while the combined multi-SF pass still exceeds it. _board->serviceWatchdog(); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f011159d..a4f771e3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -33,6 +33,7 @@ protected: int32_t _noise_floor_centi_dbm; float _last_rssi, _last_snr; bool _cad_enabled; + uint32_t _cad_scan_timeout_override_ms; bool _noise_floor_valid; bool _nf_refresh_requested; uint16_t _num_floor_samples; @@ -107,10 +108,16 @@ protected: void cacheParams(float freq, float bw, uint8_t sf, uint8_t cr) { _cur_freq = freq; _cur_bw = bw; _cur_sf = sf; _cur_cr = cr; _params_valid = true; } + unsigned long cadScanTimeoutMillis(uint8_t sf, float bw) const { + if (_cad_scan_timeout_override_ms != 0) { + return _cad_scan_timeout_override_ms; + } + return mesh::calculateCadScanTimeoutMillis(sf, bw); + } unsigned long cadScanTimeoutMillis() const { const uint8_t sf = _params_valid ? _cur_sf : getSpreadingFactor(); const float bw = _params_valid ? _cur_bw : static_cast(LORA_BW); - return mesh::calculateCadScanTimeoutMillis(sf, bw); + return cadScanTimeoutMillis(sf, bw); } int16_t performChannelScanWithTimeout(unsigned long timeout_ms); virtual int startReceiveMode(); @@ -153,6 +160,7 @@ public: last_recv_millis = 0; last_radio_interrupt_millis = 0; _cad_enabled = false; + _cad_scan_timeout_override_ms = 0; } void begin() override; @@ -210,6 +218,18 @@ public: void triggerNoiseFloorCalibrate(int threshold) override; void recalibrateNoiseFloor() override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } + bool setCADScanTimeoutMillis(uint32_t timeout_ms) { + if (timeout_ms != 0 + && (timeout_ms < mesh::CAD_SCAN_MIN_TIMEOUT_MS + || timeout_ms > mesh::CAD_SCAN_MAX_TIMEOUT_MS)) { + return false; + } + _cad_scan_timeout_override_ms = timeout_ms; + return true; + } + uint32_t getCADScanTimeoutMillis() const { + return cadScanTimeoutMillis(); + } void resetAGC() override; void loop() override; diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 626aaa7f..bdd0bf01 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -12,6 +12,24 @@ fail() { [ "$OPTION3_BUILD_WORKERS" -eq 1 ] \ || fail "logging matrix permits concurrent PlatformIO target builds" +# Interactive builds can carry the complete version from the newest artifact +# in out/, while an empty directory falls through to the existing tag-derived +# editable prompt. +version_test_dir=$(mktemp -d) +trap 'rm -rf -- "$version_test_dir"' EXIT +touch "$version_test_dir/RAK_4631_repeater-v1.17.1-dev-1234abcd.uf2" +[ "$(get_latest_output_firmware_version "$version_test_dir")" = v1.17.1-dev ] \ + || fail "did not read a semantic firmware version from output" +sleep 0.01 +touch "$version_test_dir/RAK_4631_repeater-full-ota-1.17.1.5-halo-keymind-cascade-dev-69ded9d6-merged.bin" +[ "$(get_latest_output_firmware_version "$version_test_dir")" \ + = 1.17.1.5-halo-keymind-cascade-dev ] \ + || fail "did not prefer the newest custom firmware version from output" +rm -f -- "$version_test_dir"/* +if get_latest_output_firmware_version "$version_test_dir" >/dev/null; then + fail "empty output directory unexpectedly supplied a firmware version" +fi + # Full targets are synthesized from an ordinary transport environment. Check # the resolved PlatformIO configuration so board-specific display, GPS, input, # and BLE constraints cannot silently disappear through that inheritance. diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 8b888baa..e58da943 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -96,6 +96,28 @@ TEST(CompanionNodePrefs, UsbLoggingStateIsIndependentFromTransports) { EXPECT_EQ(1, prefs.powersaving_enabled); } +TEST(CompanionNodePrefs, CadControlsAreIndependentAndDefaultable) { + CompanionNodePrefs prefs = {}; + prefs.cad_enabled = 1; + prefs.cad_scan_timeout_ms = 350; + prefs.cad_retry_delay_ms = 75; + prefs.cad_max_duration_ms = 2500; + + EXPECT_EQ(1, prefs.cad_enabled); + EXPECT_EQ(350, prefs.cad_scan_timeout_ms); + EXPECT_EQ(75, prefs.cad_retry_delay_ms); + EXPECT_EQ(2500, prefs.cad_max_duration_ms); + + // Zero means use the existing SF/BW-derived or Dispatcher adaptive timing. + prefs.cad_scan_timeout_ms = 0; + prefs.cad_retry_delay_ms = 0; + prefs.cad_max_duration_ms = 0; + EXPECT_EQ(0, prefs.cad_scan_timeout_ms); + EXPECT_EQ(0, prefs.cad_retry_delay_ms); + EXPECT_EQ(0, prefs.cad_max_duration_ms); + EXPECT_EQ(1, prefs.cad_enabled); +} + TEST(CompanionNodePrefs, BluetoothNameOverrideIsIndependentFromNodeName) { CompanionNodePrefs prefs = {}; strcpy(prefs.node_name, "RidgeNode"); diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index b11112e9..ad8d56ae 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -81,6 +81,17 @@ class TestStruct : public ConfigSerializer { uint8_t flags; }; +class DecimalStruct : public ConfigSerializer { + protected: + void structure() override { + def("latitude", latitude); + def("frequency", frequency); + } + public: + double latitude = 0.0; + float frequency = 0.0f; +}; + // ── saveSerial: basic ─────────────────────────────────────────────────────── TEST(ConfigSerializer, SaveSerial_Basic) { @@ -192,6 +203,31 @@ TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { EXPECT_TRUE(match); } +TEST(ConfigSerializer, LoadSerial_FixedDecimals) { + MockInputStream s("{latitude:-47.123456,frequency:910.5250}"); + DecimalStruct data; + + ASSERT_TRUE(data.loadSerial(s)); + EXPECT_NEAR(-47.123456, data.latitude, 0.0000001); + EXPECT_NEAR(910.525f, data.frequency, 0.0001f); +} + +TEST(ConfigSerializer, SaveAndLoadSerial_FixedDecimals) { + DecimalStruct saved; + saved.latitude = -12.345678; + saved.frequency = 62.5f; + MockPrintStream output; + ASSERT_TRUE(saved.saveSerial(output)); + + std::string encoded(reinterpret_cast(output.getBytes()), + output.getLength()); + MockInputStream input(encoded.c_str()); + DecimalStruct loaded; + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_NEAR(saved.latitude, loaded.latitude, 0.0000001); + EXPECT_NEAR(saved.frequency, loaded.frequency, 0.0001f); +} + TEST(NodePrefs, AdvertLocationDefaultsToStoredPrefs) { NodePrefs prefs; EXPECT_EQ(ADVERT_LOC_PREFS, prefs.advert_loc_policy); diff --git a/test/test_serial_mode_switch/test_serial_mode_switch.cpp b/test/test_serial_mode_switch/test_serial_mode_switch.cpp index 9c935d59..5c669461 100644 --- a/test/test_serial_mode_switch/test_serial_mode_switch.cpp +++ b/test/test_serial_mode_switch/test_serial_mode_switch.cpp @@ -7,6 +7,7 @@ #include "helpers/ArduinoSerialInterface.h" #include "helpers/MultiSerialInterface.h" +#include "helpers/UsbAsciiBinarySwitch.h" class BufferStream : public Stream { public: @@ -33,6 +34,8 @@ public: return value; } + int peek() override { return input.empty() ? -1 : input.front(); } + size_t write(uint8_t value) override { return write(&value, 1); } @@ -247,7 +250,7 @@ TEST(SerialModeSwitch, RecognizesControlSequenceAcrossReads) { EXPECT_EQ(interface.checkRecvFrame(frame), 0u); EXPECT_TRUE(interface.takeControlSequence()); EXPECT_FALSE(interface.takeControlSequence()); - EXPECT_EQ(stream.available(), 1); // trailing CR belongs to the terminal + EXPECT_EQ(stream.available(), 0); // delimiter is part of the control line } TEST(SerialModeSwitch, DoesNotScanInsideBinaryFrame) { @@ -284,7 +287,7 @@ TEST(SerialModeSwitch, RecognizesSecondaryControlSequenceSeparately) { EXPECT_FALSE(interface.takeControlSequence()); EXPECT_TRUE(interface.takeSecondaryControlSequence()); EXPECT_FALSE(interface.takeSecondaryControlSequence()); - EXPECT_EQ(stream.available(), 2); // trailing CRLF belongs to the seeder + EXPECT_EQ(stream.available(), 1); // CR is consumed; LF stays for the seeder } TEST(SerialModeSwitch, DoesNotScanSecondarySequenceInsideBinaryFrame) { @@ -321,22 +324,46 @@ TEST(SerialModeSwitch, RecognizesControlSequenceAfterBinaryFrame) { EXPECT_EQ(frame[1], 0x5A); EXPECT_FALSE(interface.takeControlSequence()); + stream.push("\r"); EXPECT_EQ(interface.checkRecvFrame(frame), 0u); EXPECT_TRUE(interface.takeControlSequence()); } -TEST(SerialModeSwitch, MismatchedPrefixDoesNotTrigger) { +TEST(SerialModeSwitch, ControlSequenceRequiresAnExactBoundedLine) { BufferStream stream; ArduinoSerialInterface interface; interface.begin(stream, START_TOKEN); interface.enable(); uint8_t frame[MAX_FRAME_SIZE] = {}; - stream.push("+++MESHCORE-TERM-ST0P"); + stream.push("prefix+++MESHCORE-TERM-START\r"); EXPECT_EQ(interface.checkRecvFrame(frame), 0u); EXPECT_FALSE(interface.takeControlSequence()); - stream.push(START_TOKEN); + stream.push("+++MESHCORE-TERM-STARTsuffix\r"); + EXPECT_EQ(interface.checkRecvFrame(frame), 0u); + EXPECT_FALSE(interface.takeControlSequence()); + + stream.push("+++MESHCORE-TERM-ST0P\r"); + EXPECT_EQ(interface.checkRecvFrame(frame), 0u); + EXPECT_FALSE(interface.takeControlSequence()); + + stream.push("+++MESHCORE-TERM-START"); + EXPECT_EQ(interface.checkRecvFrame(frame), 0u); + EXPECT_FALSE(interface.takeControlSequence()); + stream.push("\r"); + EXPECT_EQ(interface.checkRecvFrame(frame), 0u); + EXPECT_TRUE(interface.takeControlSequence()); +} + +TEST(SerialModeSwitch, NewlineStartsAFreshControlLine) { + BufferStream stream; + ArduinoSerialInterface interface; + interface.begin(stream, START_TOKEN); + interface.enable(); + uint8_t frame[MAX_FRAME_SIZE] = {}; + + stream.push("noise\r+++MESHCORE-TERM-START\n"); EXPECT_EQ(interface.checkRecvFrame(frame), 0u); EXPECT_TRUE(interface.takeControlSequence()); } @@ -363,6 +390,93 @@ TEST(SerialModeSwitch, PassthroughLeavesInputAndSuppressesBinaryOutput) { EXPECT_EQ(stream.output[0], '>'); } +TEST(SerialModeSwitch, AsciiStartupHandsUntouchedFrameToBinaryParser) { + BufferStream stream; + ArduinoSerialInterface interface; + interface.begin(stream, START_TOKEN); + interface.enable(); + interface.setPassthroughMode(true); + mesh::UsbBinaryStartupProbe probe; + uint8_t frame[MAX_FRAME_SIZE] = {}; + + const uint8_t input[] = {'<', 2, 0, 0x16, 0x03}; + stream.push(input, sizeof(input)); + ASSERT_TRUE(probe.shouldStart(true, false, stream.peek())); + const uint32_t before = interface.getCompletedFrameCount(); + interface.setPassthroughMode(false); + probe.start(50, before); + + ASSERT_EQ(interface.checkRecvFrame(frame), 2u); + EXPECT_EQ(frame[0], 0x16); // CMD_DEVICE_QUERY + EXPECT_EQ(frame[1], 0x03); + EXPECT_EQ(probe.poll(50, interface.getCompletedFrameCount(), 50), + mesh::UsbBinaryStartupProbe::Result::BINARY_CONFIRMED); +} + +TEST(SerialModeSwitch, AsciiStartupProbeRequiresAnEmptyPrompt) { + mesh::UsbBinaryStartupProbe probe; + EXPECT_TRUE(probe.shouldStart(true, false, '<')); + EXPECT_FALSE(probe.shouldStart(false, false, '<')); + EXPECT_FALSE(probe.shouldStart(true, true, '<')); + EXPECT_FALSE(probe.shouldStart(true, false, 'h')); + EXPECT_FALSE(probe.shouldStart(true, false, -1)); +} + +TEST(SerialModeSwitch, IncompleteBinaryProbeReturnsToAsciiAfterTimeout) { + mesh::UsbBinaryStartupProbe probe; + probe.start(0xFFFFFFF0u, 7); + EXPECT_EQ(probe.poll(0xFFFFFFF0u + 999u, 7), + mesh::UsbBinaryStartupProbe::Result::WAITING); + EXPECT_EQ(probe.poll(0xFFFFFFF0u + 1000u, 7), + mesh::UsbBinaryStartupProbe::Result::RETURN_TO_ASCII); + EXPECT_FALSE(probe.isActive()); +} + +TEST(SerialModeSwitch, BinaryProbeUsesTheFrameCompletionDeadline) { + mesh::UsbBinaryStartupProbe probe; + probe.start(100, 4); + EXPECT_EQ(probe.poll(1200, 5, 1099), + mesh::UsbBinaryStartupProbe::Result::BINARY_CONFIRMED); + + probe.start(100, 5); + EXPECT_EQ(probe.poll(1100, 6, 1100), + mesh::UsbBinaryStartupProbe::Result::RETURN_TO_ASCII); +} + +TEST(SerialModeSwitch, BinaryProbeTimeoutCheckHandlesMillisRollover) { + mesh::UsbBinaryStartupProbe probe; + probe.start(0xFFFFFFF0u, 1); + EXPECT_FALSE(probe.hasTimedOut(0xFFFFFFF0u + 999u)); + EXPECT_TRUE(probe.hasTimedOut(0xFFFFFFF0u + 1000u)); +} + +TEST(SerialModeSwitch, TcpCanBorrowOnlyAnIdleUnopenedAsciiTerminal) { + mesh::UsbTcpTerminalHandoff handoff; + EXPECT_FALSE(handoff.begin(true, true, true, 10)); + EXPECT_FALSE(handoff.begin(true, false, false, 10)); + EXPECT_TRUE(handoff.begin(true, false, true, 10)); + EXPECT_TRUE(handoff.isBorrowingAscii()); + EXPECT_TRUE(handoff.shouldRestoreAscii(10)); + EXPECT_FALSE(handoff.shouldRestoreAscii(10)); +} + +TEST(SerialModeSwitch, UsbBinaryActivityWinsDuringTcpBorrow) { + mesh::UsbTcpTerminalHandoff handoff; + ASSERT_TRUE(handoff.begin(true, false, true, 20)); + EXPECT_FALSE(handoff.shouldRestoreAscii(21)); + + ASSERT_TRUE(handoff.begin(false, true, false, 21)); + EXPECT_FALSE(handoff.isBorrowingAscii()); + EXPECT_FALSE(handoff.shouldRestoreAscii(21)); +} + +TEST(SerialModeSwitch, FailedMotaRestoresOnlyItsAsciiOrigin) { + EXPECT_TRUE(mesh::shouldRestoreAsciiAfterMotaFailure( + mesh::UsbMotaEntryOrigin::ASCII)); + EXPECT_FALSE(mesh::shouldRestoreAsciiAfterMotaFailure( + mesh::UsbMotaEntryOrigin::BINARY)); +} + TEST(SerialFlowControl, KeepsAFrameQueuedUntilUsbHasSpace) { BufferStream stream; stream.write_capacity = 0; diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 3ef086d9..79833b79 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -2054,12 +2054,15 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool wire_command = command_text if getattr(args, "source_companion_terminal", False): # meshcli raw mode keeps one serial open while writing this - # compound command. The full Companion consumes the start/stop - # tokens locally and runs the middle command in ASCII mode. + # compound command. STOP first makes this independent of the + # port's current state: ASCII consumes it and returns to + # Binary, while Binary ignores it as an ordinary bounded line. + # START can then enter ASCII deterministically. wire_command = ( + f"{COMPANION_TERMINAL_STOP}\r" f"{COMPANION_TERMINAL_START}\r" f"{command_text}\r" - f"{COMPANION_TERMINAL_STOP}" + f"{COMPANION_TERMINAL_STOP}\r" ) command = [ args.meshcli, @@ -2110,9 +2113,11 @@ def preflight_source_cli(args: argparse.Namespace) -> None: serial_port = args.source_cli_serial or args.source_serial if serial_port: - # Ordinary repeaters start in raw ASCII. A full Companion starts in - # Binary mode, so retry an invalid raw probe through its terminal - # control tokens and remember that transport for later TempRadio calls. + # Ordinary repeaters and current ASCII-first Full Companions answer the + # raw probe directly. Older or already-binary Full Companions need a + # terminal wrapper. Its STOP/START preamble is deliberately safe in + # either mode, so this fallback does not assume that closing the first + # raw probe caused an observable USB disconnect. args.source_companion_terminal = False output = source_cli_command(args, "ota status", check=False) if not valid_status(output): @@ -2242,6 +2247,13 @@ def verify_shared_source_identity( class SeederProcess: + READY_PATTERN = re.compile(r"(?mi)^\s*\[dev\]\s+COUNT\s*->\s*\d+\b") + ATTACH_ERROR_PATTERN = re.compile( + r"(?mi)^\s*\[dev\].*\b(?:ERR|ERROR)\b|" + r"folder\s+(?:is\s+)?already\s+(?:owned|attached)|" + r"could not (?:attach|enter).*folder" + ) + def __init__(self, args: argparse.Namespace, served_dir: Path, work_dir: Path): self.args = args self.log_path = work_dir / "motatool-serve.log" @@ -2253,8 +2265,6 @@ class SeederProcess: "--serial", args.source_serial, "--baud", str(args.source_baud), ]) - if getattr(args, "source_companion_terminal", False): - command.append("--companion-terminal") else: command.extend(["--tcp", args.source_tcp]) self.command = command @@ -2276,9 +2286,8 @@ class SeederProcess: except FileNotFoundError as exc: self.log_file.close() raise OtaError(f"required command was not found: {self.args.motatool}") from exc - time.sleep(self.args.seeder_start_wait) - self.ensure_running("during startup") - print("[seeder] running") + self._wait_until_attached() + print("[seeder] running (device COUNT confirmed)") def _log_tail(self) -> str: if self.log_file is not None and not self.log_file.closed: @@ -2300,6 +2309,27 @@ class SeederProcess: f"{self._log_tail()}" ) + def _wait_until_attached(self) -> None: + deadline = time.monotonic() + self.args.seeder_start_wait + while True: + self.ensure_running("during startup") + detail = self._log_tail() + if self.ATTACH_ERROR_PATTERN.search(detail): + raise OtaError( + "motatool seeder was rejected by the device while " + f"attaching:\n{detail}" + ) + if self.READY_PATTERN.search(detail): + return + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OtaError( + "motatool seeder did not receive the device COUNT " + f"acknowledgement within {self.args.seeder_start_wait:g}s:\n" + f"{detail}" + ) + time.sleep(min(0.1, remaining)) + def payload_read_progress(self, package: MotaInfo) -> tuple[int, int, int]: """Return unique, total, and aggregate payload-block host reads. diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 7086c35c..b1d52118 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -657,6 +657,24 @@ class SourceCliTests(unittest.TestCase): ], ) + def test_serial_preflight_accepts_ascii_first_full_companion(self) -> None: + args = argparse.Namespace( + source_serial="/dev/source", + source_cli_serial=None, + source_cli_tcp=None, + ) + with mock.patch.object( + ota, + "source_cli_command", + return_value="OTA seeder | install:disabled | target:00000000", + ) as source_command: + ota.preflight_source_cli(args) + + self.assertFalse(args.source_companion_terminal) + source_command.assert_called_once_with( + args, "ota status", check=False + ) + def test_serial_companion_command_is_wrapped_in_terminal_tokens(self) -> None: args = argparse.Namespace( source_cli_serial=None, @@ -679,13 +697,14 @@ class SourceCliTests(unittest.TestCase): wire_command = run.call_args.args[0][-1] self.assertEqual( wire_command, + "+++MESHCORE-TERM-STOP\r" "+++MESHCORE-TERM-START\r" "tempradio 909.95,250,5,5,120\r" - "+++MESHCORE-TERM-STOP", + "+++MESHCORE-TERM-STOP\r", ) self.assertIn("OK - temp params", output) - def test_serial_companion_seeder_enters_terminal_mode(self) -> None: + def test_serial_companion_seeder_uses_direct_mota_preamble(self) -> None: args = argparse.Namespace( motatool="motatool", source_serial="/dev/source", @@ -699,7 +718,86 @@ class SourceCliTests(unittest.TestCase): [ "motatool", "serve", "--dir", "/served", "-v", "--serial", "/dev/source", "--baud", "115200", - "--companion-terminal", + ], + ) + + def test_seeder_readiness_requires_device_count(self) -> None: + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) + args = argparse.Namespace( + motatool="motatool", + source_serial="/dev/source", + source_tcp=None, + source_baud=115200, + seeder_start_wait=1.0, + ) + seeder = ota.SeederProcess(args, Path("/served"), work_dir) + seeder.process = mock.MagicMock() + seeder.process.poll.return_value = None + seeder.log_path.write_text( + "[host] serving /served\n[dev] COUNT -> 3\n", + encoding="utf-8", + ) + + seeder._wait_until_attached() + + def test_seeder_readiness_surfaces_device_attach_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) + args = argparse.Namespace( + motatool="motatool", + source_serial="/dev/source", + source_tcp=None, + source_baud=115200, + seeder_start_wait=1.0, + ) + seeder = ota.SeederProcess(args, Path("/served"), work_dir) + seeder.process = mock.MagicMock() + seeder.process.poll.return_value = None + seeder.log_path.write_text( + "[dev] ERR folder source unavailable\n", encoding="utf-8" + ) + + with self.assertRaisesRegex(ota.OtaError, "rejected by the device"): + seeder._wait_until_attached() + + def test_seeder_readiness_times_out_without_count(self) -> None: + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) + args = argparse.Namespace( + motatool="motatool", + source_serial="/dev/source", + source_tcp=None, + source_baud=115200, + seeder_start_wait=1.0, + ) + seeder = ota.SeederProcess(args, Path("/served"), work_dir) + seeder.process = mock.MagicMock() + seeder.process.poll.return_value = None + seeder.log_path.write_text( + "[host] serving /served\n", encoding="utf-8" + ) + + with ( + mock.patch.object(ota.time, "monotonic", side_effect=(10.0, 11.0)), + self.assertRaisesRegex(ota.OtaError, "device COUNT"), + ): + seeder._wait_until_attached() + + def test_ascii_first_full_companion_seeder_uses_direct_preamble(self) -> None: + args = argparse.Namespace( + motatool="motatool", + source_serial="/dev/source", + source_tcp=None, + source_baud=115200, + source_companion_terminal=False, + ) + seeder = ota.SeederProcess(args, Path("/served"), Path("/work")) + self.assertEqual( + seeder.command, + [ + "motatool", "serve", "--dir", "/served", "-v", + "--serial", "/dev/source", "--baud", "115200", ], )