feat: unify companion builds and USB runtime modes

This commit is contained in:
mikecarper
2026-08-27 15:06:26 -07:00
parent 69ded9d6c7
commit 1d836c0473
31 changed files with 1425 additions and 131 deletions
+126 -11
View File
@@ -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"
+2 -2
View File
@@ -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
+59 -24
View File
@@ -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
```
+7 -4
View File
@@ -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.
+251
View File
@@ -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).
+1
View File
@@ -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)
+20 -11
View File
@@ -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 \
+9 -5
View File
@@ -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
+7 -7
View File
@@ -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
+14 -4
View File
@@ -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
```
+30
View File
@@ -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);
+169 -2
View File
@@ -6,6 +6,7 @@
#include <helpers/IdentityGeneration.h>
#include "helpers/radiolib/RXPowerSaving.h"
#include "helpers/radiolib/RxBoostedGainDefaults.h"
#include "helpers/radiolib/CadTiming.h"
#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
#include <esp_heap_caps.h>
@@ -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 <auto|100-3500> <auto|1-60000> <auto|1-60000>");
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 <on|off> or set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>");
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 <off|on|level 1-10 [preamble 16|32]|rx_us sleep_us>\r\n");
terminalOutput().print(" get radio.cad\r\n");
terminalOutput().print(" set radio.cad <on|off>\r\n");
terminalOutput().print(" set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>\r\n");
terminalOutput().print(" get radio.rxgain\r\n");
terminalOutput().print(" set radio.rxgain <on|off>\r\n");
terminalOutput().print(" get radio.fem.rxgain\r\n");
+3
View File
@@ -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);
+4
View File
@@ -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.
+140 -10
View File
@@ -81,12 +81,13 @@ MultiSerialInterface interface_manager;
#if defined(ENABLE_USB_INTERFACE)
#include <helpers/ArduinoSerialInterface.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/UsbAsciiBinarySwitch.h>
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
+2 -2
View File
@@ -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 {
+5
View File
@@ -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; }
+39 -22
View File
@@ -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;
}
+11 -4
View File
@@ -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.
+30 -2
View File
@@ -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());
}
}
}
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <stdint.h>
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
+34
View File
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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();
+21 -1
View File
@@ -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<float>(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;
+18
View File
@@ -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.
@@ -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");
@@ -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<const char*>(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);
@@ -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;
+41 -11
View File
@@ -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.
+101 -3
View File
@@ -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",
],
)