Add full Companion OTA source builds

This commit is contained in:
mikecarper
2026-08-10 12:39:07 -07:00
parent 1bceda54a6
commit 88ba5b26cb
19 changed files with 1212 additions and 86 deletions
+166 -8
View File
@@ -43,7 +43,7 @@ PARSED_COMMAND_ARGS=()
FIRMWARE_VERSION_EXPLICIT=0
OUTPUT_POLICY_EXPLICIT=0
ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater'
ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater'
BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display"
BOARD_MODIFIER_LOGGING="_logging"
BOARD_MODIFIER_TFT="_tft"
@@ -60,7 +60,7 @@ TAG_PREFIX_COMPANION="companion"
TAG_PREFIX_REPEATER="repeater"
TAG_PREFIX_SENSOR="sensor"
SUPPORTED_PLATFORM_PATTERN='ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM'
OUTPUT_DIR="out"
OUTPUT_DIR="${OUTPUT_DIR:-out}"
ESP32_LORA_OTA_APP_LIMIT=$((0x150000 - 0x10000))
REPEATER_MAX_NEIGHBOURS=254
DRAM_LIMITED_MAX_NEIGHBOURS=50
@@ -147,6 +147,7 @@ Environment Variables:
If not set, debug flags from variant platformio.ini files are used.
RESUME_BUILD_OUTPUT=1: Preserves out/ and skips targets whose expected output
artifacts already exist. Option 3 resumes by default.
OUTPUT_DIR=path: Writes artifacts outside out/ (useful for isolated test builds).
OPTION3_BUILD_WORKERS=2: Concurrent targets per Option 3 profile pass.
OPTION3_PIO_JOBS=8: Compiler jobs assigned to each concurrent Option 3 target.
@@ -253,7 +254,7 @@ for section, options in data:
# sensors), and expose a separately named no-external-sensors OTA build for
# every ESP32/nRF52 repeater role. These two platforms have a complete apply
# path; RP2040 and STM32 do not yet have the required bootloader/apply path.
local env_name ota_env
local env_name ota_env full_env usb_env ble_env
local -a base_envs=("${SUPPORTED_PIO_ENVS[@]}")
for env_name in "${base_envs[@]}"; do
case "${PIO_ENV_PLATFORM_BY_NAME[$env_name]}" in
@@ -281,6 +282,76 @@ for section, options in data:
PIO_ENV_FULL_WIFI_OTA_BY_NAME["$ota_env"]=0
PIO_ENV_BUILD_BASE_BY_NAME["$ota_env"]="$env_name"
done
# An ESP32 full companion is built from the board's WiFi companion recipe.
# Expose it only when the exact same board also has USB and BLE companion
# recipes, so enabling the three transports cannot silently pull in
# assumptions from a different hardware variant.
#
# The full role is a LoRa mOTA *source*, not a LoRa update destination. Its
# build overlay below retains the OTA protocol and TCP folder seeder while
# omitting flash staging/self-install support.
base_envs=("${SUPPORTED_PIO_ENVS[@]}")
for env_name in "${base_envs[@]}"; do
case "$env_name" in
*companion_radio_wifi_mqtt*) continue ;;
*companion_radio_wifi*) ;;
*) continue ;;
esac
[ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "ESP32_PLATFORM" ] || continue
full_env=${env_name/companion_radio_wifi/companion_radio_full}
usb_env=${env_name/companion_radio_wifi/companion_radio_usb}
ble_env=${env_name/companion_radio_wifi/companion_radio_ble}
if [ -n "${PIO_ENV_PLATFORM_BY_NAME[$full_env]+x}" ] \
|| [ "${PIO_ENV_PLATFORM_BY_NAME[$usb_env]:-}" != "ESP32_PLATFORM" ] \
|| [ "${PIO_ENV_PLATFORM_BY_NAME[$ble_env]:-}" != "ESP32_PLATFORM" ] \
|| [ "${PIO_ENV_BOARD_BY_NAME[$usb_env]:-}" != "${PIO_ENV_BOARD_BY_NAME[$env_name]:-}" ] \
|| [ "${PIO_ENV_BOARD_BY_NAME[$ble_env]:-}" != "${PIO_ENV_BOARD_BY_NAME[$env_name]:-}" ]; then
continue
fi
SUPPORTED_PIO_ENVS+=("$full_env")
PIO_ENV_PLATFORM_BY_NAME["$full_env"]="ESP32_PLATFORM"
PIO_ENV_BOARD_BY_NAME["$full_env"]="${PIO_ENV_BOARD_BY_NAME[$env_name]}"
PIO_ENV_MQTT_BY_NAME["$full_env"]=0
PIO_ENV_OTA_BY_NAME["$full_env"]=1
PIO_ENV_SD_OTA_BY_NAME["$full_env"]=0
PIO_ENV_FULL_BUILD_BY_NAME["$full_env"]=0
PIO_ENV_FULL_WIFI_OTA_BY_NAME["$full_env"]="${PIO_ENV_FULL_WIFI_OTA_BY_NAME[$env_name]:-0}"
PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$env_name"
done
# An nRF52 full companion is built from the board's USB recipe and adds
# BLE plus a source-only serial mOTA mode. nRF52840 has no native WiFi, so
# this profile deliberately has no TCP/WebConfig surface. Match exact USB
# and BLE environment names (including display/FEM modifiers) and boards.
base_envs=("${SUPPORTED_PIO_ENVS[@]}")
for env_name in "${base_envs[@]}"; do
case "$env_name" in
*companion_radio_usb*) ;;
*) continue ;;
esac
[ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "NRF52_PLATFORM" ] || continue
full_env=${env_name/companion_radio_usb/companion_radio_full}
ble_env=${env_name/companion_radio_usb/companion_radio_ble}
if [ -n "${PIO_ENV_PLATFORM_BY_NAME[$full_env]+x}" ] \
|| [ "${PIO_ENV_PLATFORM_BY_NAME[$ble_env]:-}" != "NRF52_PLATFORM" ] \
|| [ "${PIO_ENV_BOARD_BY_NAME[$ble_env]:-}" != "${PIO_ENV_BOARD_BY_NAME[$env_name]:-}" ]; then
continue
fi
SUPPORTED_PIO_ENVS+=("$full_env")
PIO_ENV_PLATFORM_BY_NAME["$full_env"]="NRF52_PLATFORM"
PIO_ENV_BOARD_BY_NAME["$full_env"]="${PIO_ENV_BOARD_BY_NAME[$env_name]}"
PIO_ENV_MQTT_BY_NAME["$full_env"]=0
PIO_ENV_OTA_BY_NAME["$full_env"]=1
PIO_ENV_SD_OTA_BY_NAME["$full_env"]=0
PIO_ENV_FULL_BUILD_BY_NAME["$full_env"]=0
PIO_ENV_FULL_WIFI_OTA_BY_NAME["$full_env"]=0
PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$env_name"
done
fi
}
@@ -1001,7 +1072,7 @@ get_variants_for_board() {
local board_family=$1
local env
for env in "${ALL_PIO_ENVS[@]}"; do
for env in "${SUPPORTED_PIO_ENVS[@]}"; do
if ! is_supported_build_env "$env"; then
continue
fi
@@ -1083,7 +1154,7 @@ prompt_for_board_target() {
exit 1
fi
for env in "${ALL_PIO_ENVS[@]}"; do
for env in "${SUPPORTED_PIO_ENVS[@]}"; do
if ! is_supported_build_env "$env"; then
continue
fi
@@ -1262,7 +1333,7 @@ get_pio_envs_ending_with_string() {
local env
shopt -s nocasematch
for env in "${ALL_PIO_ENVS[@]}"; do
for env in "${SUPPORTED_PIO_ENVS[@]}"; do
if is_supported_build_env "$env" && [[ "$env" == *${suffix} ]]; then
printf '%s\n' "$env"
fi
@@ -1293,7 +1364,7 @@ get_pio_envs_for_variant_role() {
local env
local variant_name
for env in "${ALL_PIO_ENVS[@]}"; do
for env in "${SUPPORTED_PIO_ENVS[@]}"; do
if ! is_supported_build_env "$env"; then
continue
fi
@@ -1770,6 +1841,27 @@ is_esp32_usb_wifi_companion_ota_build() {
esac
}
is_companion_radio_full_target() {
case "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" in
ESP32_PLATFORM|NRF52_PLATFORM) ;;
*) return 1 ;;
esac
case "${1,,}" in
*companion_radio_full*) return 0 ;;
*) return 1 ;;
esac
}
is_esp32_companion_radio_full_target() {
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] \
&& is_companion_radio_full_target "$1"
}
is_nrf52_companion_radio_full_target() {
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "NRF52_PLATFORM" ] \
&& is_companion_radio_full_target "$1"
}
requires_esp32_companion_full_ota_fallback() {
# Some classic ESP32 companions cannot hold their configured high-capacity
# contact, channel, and offline-queue tables together with LoRa OTA in
@@ -2096,6 +2188,13 @@ apply_nrf52_lora_ota_build_recipe() {
apply_lora_ota_override() {
local env_name=$1
# The full companion has its own serve-only overlay. Do not run the generic
# install-capable/disabled switch first: PlatformIO build_unflags would also
# remove the overlay's later ENABLE_OTA definition.
if is_companion_radio_full_target "$env_name"; then
return 0
fi
if is_lora_ota_build "$env_name"; then
if [ "${PIO_ENV_SD_OTA_BY_NAME[$env_name]:-0}" = "1" ]; then
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1"
@@ -2110,6 +2209,57 @@ apply_lora_ota_override() {
fi
}
apply_companion_radio_full_profile() {
local env_name=$1
local pio_env_name=$2
is_companion_radio_full_target "$env_name" || return 0
# Every full Companion is a LoRa mOTA source, never an update destination.
# Remove inherited staging/install stores before adding the platform's host
# folder transport.
append_platformio_build_unflags "-UENABLE_OTA -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DDISABLE_LORA_OTA=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456"
if is_nrf52_companion_radio_full_target "$env_name"; then
# The USB stream starts as Binary Companion. `motatool serve --serial`
# switches it into an exclusive host-folder mode with its existing
# `ota folder on` preamble; BLE remains an independent Companion link.
append_platformio_build_unflags "-UOTA_FOLDER_SERIAL"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1"
if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/"; then
append_platformio_build_src_filter "+<helpers/ota/*.cpp>"
fi
if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/nrf52/*.cpp" \
&& ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/nrf52/SerialBLEInterface.cpp"; then
append_platformio_build_src_filter "+<helpers/nrf52/SerialBLEInterface.cpp>"
fi
return 0
fi
# ESP32 inherits the WiFi companion recipe and uses its dedicated TCP folder
# seeder instead of multiplexing mOTA data onto USB.
append_platformio_build_unflags "-DOTA_FOLDER_SERIAL"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UOTA_FOLDER_SERIAL -DWIFI_OTA_SEEDER=1"
# BLE + WiFi exhaust internal DRAM on the two high-capacity classic ESP32
# recipes. Use the same measured-safe tables as their existing FULL OTA
# fallback without changing ordinary USB/BLE/WiFi companion builds.
if requires_esp32_companion_full_ota_fallback "$pio_env_name"; then
append_platformio_build_unflags "-DMAX_CONTACTS=160 -DMAX_GROUP_CHANNELS=40 -DOFFLINE_QUEUE_SIZE=128"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMAX_CONTACTS=100 -DMAX_GROUP_CHANNELS=8 -DOFFLINE_QUEUE_SIZE=16"
fi
# A few WiFi recipes list only their WiFi implementation instead of the
# helpers/esp32 wildcard used by newer boards. Add the BLE implementation
# explicitly when the inherited source filter does not already include it.
if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/esp32/*.cpp" \
&& ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/esp32/SerialBLEInterface.cpp"; then
append_platformio_build_src_filter "+<helpers/esp32/SerialBLEInterface.cpp>"
fi
}
apply_radio_overrides() {
if [ -n "$RADIO_FREQ_OVERRIDE" ] && [ -n "$RADIO_BW_OVERRIDE" ] && [ -n "$RADIO_SF_OVERRIDE" ] && [ -n "$RADIO_CR_OVERRIDE" ]; then
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DLORA_FREQ=${RADIO_FREQ_OVERRIDE} -DLORA_BW=${RADIO_BW_OVERRIDE} -DLORA_SF=${RADIO_SF_OVERRIDE} -DLORA_CR=${RADIO_CR_OVERRIDE}"
@@ -2456,6 +2606,7 @@ build_firmware() {
apply_mqtt_bridge_override
disable_usb_logging_for_mqtt "$env_name"
apply_lora_ota_override "$env_name"
apply_companion_radio_full_profile "$env_name" "$pio_env_name"
apply_nrf52_lora_ota_build_recipe "$env_name" "$pio_env_name"
apply_esp32_lora_ota_size_profile "$env_name"
apply_esp32_full_size_profile "$env_name"
@@ -2464,8 +2615,13 @@ build_firmware() {
apply_radio_overrides
apply_firmware_profile_overrides
if [ "$ESP32_FULL_BUILD" = "1" ]; then
if [ "$ESP32_FULL_BUILD" = "1" ] || is_esp32_companion_radio_full_target "$env_name"; then
export MESHCORE_ESP32_FULL_BUILD=1
if is_esp32_companion_radio_full_target "$env_name"; then
export MESHCORE_COMPANION_RADIO_FULL=1
else
unset MESHCORE_COMPANION_RADIO_FULL
fi
target_extra_scripts=$original_platformio_extra_scripts
if [[ "$target_extra_scripts" != *"scripts/esp32_full_partition.py"* ]]; then
if [ -n "$target_extra_scripts" ]; then
@@ -2476,6 +2632,7 @@ build_firmware() {
export PLATFORMIO_EXTRA_SCRIPTS="$target_extra_scripts"
else
unset MESHCORE_ESP32_FULL_BUILD
unset MESHCORE_COMPANION_RADIO_FULL
fi
print_build_flags "$env_name"
@@ -2497,6 +2654,7 @@ build_firmware() {
restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags"
unset MESHCORE_ESP32_FULL_BUILD
unset MESHCORE_COMPANION_RADIO_FULL
if [ "$had_platformio_build_unflags" -eq 1 ]; then
export PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags"
else
+9
View File
@@ -175,6 +175,12 @@ A `*_companion_radio_wifi` build replaces the BLE or USB companion link with
the MeshCore companion protocol over TCP port 5000. The phone or computer must
be able to reach the device on the same LAN.
An ESP32 `*_companion_radio_full` target keeps all three Companion links at
once: USB, BLE, and TCP port 5000. It also provides a source-only LoRa mOTA
service on ports 5001 and 5002. See the
[full Companion guide](./companion_radio_full.md) for its build, terminal mode,
and complete update-source workflow.
The companion loads runtime credentials saved in NVS. A non-placeholder
compile-time `WIFI_SSID`/`WIFI_PWD` can be used as a first-boot fallback, but
saved credentials take priority. With no credentials, its WebConfig portal
@@ -196,6 +202,9 @@ When `ENABLE_OTA` is included, a WiFi companion also listens on:
- TCP 5002 for the OTA text console.
These ports do not replace the companion protocol on TCP 5000.
On a `companion_radio_full` build, port 5002 additionally accepts bounded
`tempradio` and `normalradio` commands, while LoRa staging and installation on
the Companion itself are disabled.
FULL ESP32 builds share the port 5001 folder seeder. It starts whenever that
role has a usable WiFi station or setup access point and stops when WiFi stops.
+212
View File
@@ -0,0 +1,212 @@
# Full Companion
`companion_radio_full` combines every Companion transport available on its
platform and acts as a host-backed LoRa mOTA source for updating other nodes.
The full Companion is deliberately not a LoRa OTA destination: it has no
firmware staging store, refuses `ota install`, and never advertises its own
firmware as an mOTA image.
| Capability | ESP32 full | nRF52 full |
| --- | --- | --- |
| USB Binary Companion | Yes | Yes |
| BLE Binary Companion | Yes | Yes |
| USB ASCII terminal | Yes | Yes |
| Host-backed LoRa mOTA source | WiFi TCP 5001 | Exclusive USB mode |
| WiFi Companion/WebConfig | Yes | No - nRF52840 has no WiFi |
| LoRa self-update | No | No |
## Build and install
The target is synthesized by `build.sh` only when matching transport recipes
exist for the exact board variant:
- ESP32 requires matching WiFi, USB, and BLE Companion environments.
- nRF52 requires matching USB and BLE Companion environments.
List the available targets:
```bash
bash build.sh list | grep companion_radio_full
```
Build by using one exact listed name:
```bash
bash build.sh build-firmware heltec_v4_r8_companion_radio_full \
--firmware-version v1.17.0
bash build.sh build-firmware RAK_4631_companion_radio_full \
--firmware-version v1.17.0
```
Artifacts are written to `out/` by default.
On 4 MB ESP32 boards, the full target uses a single 3 MB application
partition so WiFi, BLE, WebConfig, and source-only mOTA fit together. Flash
the generated `-merged.bin` when first installing this partition layout.
Boards with 8 MB or more retain dual application partitions. Heltec V2 and
TLora V2 use 100 contacts, 8 group channels, and a 16-frame offline queue in
this combined profile because of internal DRAM limits.
The nRF52 target inherits the board's ordinary USB Companion installation
format and adds BLE plus the serial mOTA source. It does not enable an SD cache
or any other board-specific storage behavior; host files are streamed as they
are requested.
## Interfaces
| Platform | Interface | Purpose |
| --- | --- | --- |
| Both | USB, 115200 baud | Binary Companion by default; terminal switch available |
| Both | BLE | Binary Companion; default pairing PIN `123456` |
| ESP32 | TCP 5000 | Binary Companion over WiFi |
| ESP32 | HTTP 80 | Companion WebConfig and first-boot WiFi setup |
| ESP32 | TCP 5001 | Host `.mota` folder from `motatool serve --tcp` |
| ESP32 | TCP 5002 | Local `ota`, `tempradio`, and `normalradio` console |
| nRF52 | USB mOTA mode | Host `.mota` folder from `motatool serve --serial` |
Binary Companion replies are broadcast through the multi-interface manager,
so use one active Companion application at a time. On nRF52, BLE remains
available while USB is in terminal or mOTA mode.
ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer.
Expose them only on a trusted LAN or temporary setup network. See
[WiFi setup](./WiFi.md) for credential setup and reconnect behavior.
## USB Binary and terminal modes
USB starts in Binary mode for MeshCore apps and `meshcli`:
```bash
meshcli -s /dev/ttyACM0 -b 115200 ver
```
Open the port at 115200 and send the terminal start token as an exact line:
```bash
picocom -b 115200 /dev/ttyACM0
+++MESHCORE-TERM-START
```
The terminal supports Companion chat commands plus local `ota`, `tempradio`,
and `normalradio` controls. Return to Binary mode with:
```text
+++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.
## 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.
While mOTA mode owns USB:
- mOTA manifests and blocks stream from the computer on demand.
- USB Binary and the USB text terminal are unavailable.
- BLE Binary Companion remains available.
- `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.
## Serve mOTA images manually
First put the destination, required relays, controller, and source on the same
bounded TempRadio tuple. The example frequency below is not legal everywhere;
choose a legal tuple supported by every participating radio.
### ESP32 source
Use the local console to start TempRadio:
```bash
nc 192.168.1.50 5002
```
```text
tempradio 909.950,250,7,5,120
ota status
```
Then start the dedicated TCP seeder:
```bash
motatool serve --dir ./motas --tcp 192.168.1.50:5001 -v
```
### nRF52 source
Use the USB terminal briefly to schedule TempRadio, then return to Binary mode
and close the terminal:
```text
+++MESHCORE-TERM-START
tempradio 909.950,250,7,5,120
+++MESHCORE-TERM-STOP
```
Start the serial seeder on that same port:
```bash
motatool serve --dir ./motas --serial /dev/ttyACM1 --baud 115200 -v
```
`motatool` switches the port into mOTA mode automatically. Stop it with
Ctrl-C to detach the folder. Reopen the terminal and use `normalradio` if the
source should return early; otherwise the saved radio settings return when the
bounded window expires.
Both platforms intentionally refuse firmware installation commands such as:
```text
ota pull <id> flash
ota install
ota dev ...
```
## Script a complete update
The Bash and PowerShell wrappers accept a release ZIP or ready `.mota`, set up
TempRadio, run `motatool`, monitor the exact image, install it on the
destination, and restore the radio path. Use a separate Companion as the
controller.
For an ESP32 full source:
```bash
export MESHCORE_ADMIN_PASSWORD='target-admin-password'
./tools/lora_ota/lora_ota.sh ./release.zip "Roof Node" \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.50:5001 \
--source-cli-tcp 192.168.1.50:5002
```
For an nRF52 full source, the script automatically detects the token-switched
terminal and uses the same source port sequentially for control and seeding:
```bash
export MESHCORE_ADMIN_PASSWORD='target-admin-password'
./tools/lora_ota/lora_ota.sh ./release.zip "Roof Node" \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1
```
```powershell
$env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'
& .\tools\lora_ota\lora_ota.ps1 '.\release.zip' 'Roof Node' `
--controller-serial COM7 `
--source-serial COM8
```
See the [start-to-finish LoRa OTA guide](./lora_ota_automation.md) for package
selection, nRF52 in-place deltas, relays, trust checks, and recovery behavior.
+1
View File
@@ -14,6 +14,7 @@ Below are a few quick start guides.
- [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md)
- [GPS Tracking](./gps_tracking.md)
- [Companion Protocol](./companion_protocol.md)
- [Full Companion: ESP32 and nRF52](./companion_radio_full.md)
- [Packet Format](./packet_format.md)
- [QR Codes](./qr_codes.md)
+52 -14
View File
@@ -28,9 +28,10 @@ computer -- MeshCore binary API --> controller Companion -------------------+
script uses `meshcli` to send remote admin commands to the target and changes
the controller's live radio parameters during the transfer. A serial
Companion stays in its normal **Binary** USB mode at 115200 baud.
- **OTA source:** a separate OTA-enabled repeater or FULL node whose USB port
is a raw text CLI and supports `ota folder on`. `motatool` uses that same
link for its binary seeder frames after enabling the folder.
- **OTA source:** an OTA-enabled repeater/FULL node whose USB port is a raw
text CLI, an ESP32 `companion_radio_full` using WiFi ports 5001 and 5002, or
an nRF52 `companion_radio_full` whose USB port switches between Binary,
terminal control, and exclusive mOTA seeding.
- **Target:** an OTA-enabled ESP32 or nRF52 node present in the controller's
contact list. Its admin password is required.
- **Relays:** optional. They do not need to install OTA themselves, but every
@@ -41,11 +42,11 @@ One serial port cannot serve both controller roles: `meshcli` must keep
reopening the controller while `motatool` owns the source port. The script
rejects an attempt to use the same port for both.
The Companion USB ASCII switch (`+++MESHCORE-TERM-START`) is an interactive
chat terminal, not the raw repeater/FULL management CLI expected by
`motatool`. It does not make a Companion's serial port an OTA seeder. For a
WiFi seeder, use a FULL/repeater source's port 5001 plus its raw USB CLI, as
shown below.
The USB ASCII switch (`+++MESHCORE-TERM-START`) is the local control path, not
the mOTA data framing. On an nRF52 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 port into exclusive mOTA mode. BLE remains available during that mode.
## Destination requirements
@@ -120,15 +121,27 @@ Test the controller's binary API:
meshcli -s /dev/ttyACM0 -b 115200 ver
```
Test the source's raw text CLI and OTA support:
For an ordinary raw-text source, test its OTA support:
```bash
meshcli -r -s /dev/ttyACM1 -b 115200 "ota status"
```
The second command must print an `OTA | ... target:XXXXXXXX` status. The
automation repeats this preflight and stops before changing any radios if the
source is the wrong build or interface.
The command must print an `OTA | ... target:XXXXXXXX` status.
For an nRF52 full Companion, open the source port in a terminal, send
`+++MESHCORE-TERM-START`, and run `ota status`. It must report `OTA seeder`,
`install:disabled`, and target `00000000`; send `+++MESHCORE-TERM-STOP` before
closing the terminal. The automation detects and performs this token-wrapped
preflight itself, so no extra command-line option is needed.
For an ESP32 full Companion, test its separate WiFi control console instead:
```bash
printf 'ota status\r\n' | nc 192.168.1.50 5002
```
It must report `OTA seeder`, `install:disabled`, and target `00000000`.
Changing a terminal to 57600 baud does not select ASCII mode. USB Companion
builds and the normal raw management CLI use 115200 unless a particular build
@@ -280,6 +293,31 @@ while its raw USB CLI is used to start TempRadio:
--source-cli-serial /dev/ttyACM1
```
An ESP32 `companion_radio_full` uses WiFi for both dedicated source links:
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.50:5001 \
--source-cli-tcp 192.168.1.50:5002
```
Port 5002 defaults automatically when it is omitted from
`--source-cli-tcp`. The source-only Companion never stages or installs the
image itself; it streams the host folder to other nodes over LoRa. See the
[full Companion guide](./companion_radio_full.md) for manual operation and
interface details.
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:
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1
```
If the source is already on the exact TempRadio tuple through a scheduled or
manual operation, `--source-already-temp` lets a TCP source run without a raw
CLI link. The script cannot verify, extend, or shorten that source window, so
@@ -338,8 +376,8 @@ the destination.
## What happens during a run
1. Validate the input paths and host tools, then prove the source is an
OTA-enabled raw CLI.
1. Validate the input paths and host tools, then prove the source is either an
OTA-enabled raw CLI or a source-only full Companion control interface.
2. Authenticate to the target and query its target ID, hardware, running body
hash, version, and nRF52 bootloader capabilities.
3. Select or build one compatible mOTA and verify all block hashes, Merkle
+20 -6
View File
@@ -47,8 +47,8 @@ Both paths require:
that the node can discover, download, verify, and install LoRa OTA. Intermediate repeaters do **not** need an
OTA-enabled build: current repeater firmware relays OTA packets opaquely without storing or installing them.
Portable logging, portable MQTT, and untagged builds cannot install LoRa OTA; FULL logging OTA builds can.
- An OTA-enabled MeshCore source connected to the computer by USB serial, or an ESP32 WiFi companion/FULL
ESP32 source connected over WiFi as described below.
- An OTA-enabled MeshCore source connected to the computer by USB serial, or
an ESP32 WiFi companion/FULL source connected over WiFi as described below.
- Overlapping `tempradio` windows on the source, destination, and every repeater needed between them.
LoRa OTA packets are generated, consumed, and relayed only while `tempradio` is active. Intermediate repeaters
@@ -74,12 +74,18 @@ substantial serial output.
### Choose the source radio
Use an OTA-enabled MeshCore node as the source. It receives the update folder from the computer, then
advertises it over LoRa. ESP32 USB/WiFi companions and FULL ESP32 roles include the required transport.
Use an OTA-enabled MeshCore node as the source. It receives the update folder
from the computer, then advertises it over LoRa. ESP32 USB/WiFi companions and
FULL ESP32 roles include the required transport. A
`*_companion_radio_full` target keeps only the source half of LoRa OTA: it
serves host images but cannot stage or install one for itself. ESP32 full
combines USB, BLE, and WiFi; nRF52 full combines USB and BLE because nRF52840
has no WiFi.
A small set of high-capacity classic ESP32 companions
keep their normal image and provide a separate `-full-ota-` image with 100 contacts, 8 group channels, and
a 16-frame offline queue. Install that variant's merged image over USB once before using it. Connect the
source by USB serial or, when supported, by WiFi. For USB serial, confirm that its USB CLI accepts:
source by USB serial or, when supported, by WiFi. For an ordinary raw-text USB
source, confirm that its USB CLI accepts:
```text
ota folder on
@@ -89,6 +95,12 @@ If an older build reports that `OTA_FOLDER_SERIAL` is not compiled in, install a
`-full-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.
For an ESP32 WiFi companion or FULL ESP32 source with active WiFi, use its dedicated OTA seeder:
```bash
@@ -96,7 +108,9 @@ motatool serve --dir ./motas --tcp <source-host>:5001 -v
```
Port `5001` is separate from the companion application port (`5000`) and the
infrastructure WebConfig/browser-OTA port (`80`). On a FULL repeater or room
HTTP configuration/browser-OTA port (`80`, depending on the role). An ESP32
`companion_radio_full` also has a local OTA/TempRadio console on port `5002`;
see the [full Companion guide](./companion_radio_full.md). On a FULL repeater or room
server, `start webconfig` can bring up the saved WiFi connection. Other FULL
roles with browser OTA support can raise `MeshCore-OTA` with `start ota` and
use `192.168.4.1:5001`. The TCP seeder auto-attaches; do not also run
+202 -1
View File
@@ -4,6 +4,10 @@
#include <Mesh.h>
#include "helpers/radiolib/RXPowerSaving.h"
#if defined(COMPANION_RADIO_FULL)
#include <helpers/ota/OtaCli.h>
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/MQTTDefaults.h>
#endif
@@ -1215,6 +1219,17 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
command_radio_cr = 0;
command_radio_repeat = 0;
command_radio_apply_deadline = 0;
#if defined(COMPANION_RADIO_FULL)
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_freq = 0.0f;
_temp_radio_bw = 0.0f;
_temp_radio_sf = 0;
_temp_radio_cr = 0;
_temp_radio_failures = 0;
_temp_radio_applied = false;
#endif
offline_queue_len = 0;
app_target_ver = 0;
clearPendingReqs();
@@ -1487,6 +1502,159 @@ void MyMesh::servicePendingRadioParamApply() {
}
}
#if defined(COMPANION_RADIO_FULL)
static bool isFullCompanionBandwidth(float bw) {
static const float supported[] = {
7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f,
62.5f, 125.0f, 250.0f, 500.0f
};
for (float candidate : supported) {
if (fabsf(candidate - bw) <= 0.01f) return true;
}
return false;
}
bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t timeout_mins, char* reply,
size_t reply_size) {
if (command_radio_apply_pending) {
snprintf(reply, reply_size, "ERR companion radio change is already pending");
return false;
}
_temp_radio_freq = freq;
_temp_radio_bw = bw;
_temp_radio_sf = sf;
_temp_radio_cr = cr;
_temp_radio_set_at = futureMillis(1500); // let the local reply drain first
_temp_radio_revert_at = futureMillis(1500 + (int)(timeout_mins * 60000UL));
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
snprintf(reply, reply_size, "OK - temp params for %lu mins",
(unsigned long)timeout_mins);
return true;
}
void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) {
_temp_radio_set_at = 0;
_temp_radio_revert_at = futureMillis(1500); // keep the reply on the active tuple
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
snprintf(reply, reply_size, "OK - normal radio restore scheduled");
}
bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
size_t reply_size) {
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
if (strcmp(command, "tempradio") == 0) {
if (_temp_radio_set_at) {
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr);
} else if (isTempRadioActive()) {
uint32_t seconds = (_temp_radio_revert_at - _ms->getMillis()) / 1000UL;
snprintf(reply, reply_size, "TempRadio active: %.3f,%.2f,%u,%u %lus left",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr,
(unsigned long)seconds);
} else {
snprintf(reply, reply_size, "TempRadio inactive");
}
return true;
}
if (strncmp(command, "tempradio ", 10) == 0) {
float freq = 0.0f, bw = 0.0f;
int sf = 0, cr = 0;
unsigned long timeout_mins = 0;
char extra = 0;
if (sscanf(command + 10, "%f,%f,%d,%d,%lu%c",
&freq, &bw, &sf, &cr, &timeout_mins, &extra) != 5
|| !isfinite(freq) || !isfinite(bw)
|| freq < 150.0f || freq > 2500.0f
|| !isFullCompanionBandwidth(bw)
|| sf < 5 || sf > 12 || cr < 5 || cr > 8
|| timeout_mins == 0 || timeout_mins > 10080UL) {
snprintf(reply, reply_size,
"ERR usage: tempradio freq,bw,sf,cr,minutes (minutes 1-10080)");
return true;
}
scheduleTempRadio(freq, bw, (uint8_t)sf, (uint8_t)cr,
(uint32_t)timeout_mins, reply, reply_size);
return true;
}
if (strcmp(command, "normalradio") == 0) {
scheduleNormalRadio(reply, reply_size);
return true;
}
if (strncmp(command, "ota", 3) == 0
&& (command[3] == 0 || command[3] == ' ')) {
char ota_reply[160] = {0};
if (!mesh::ota::handle_ota_command(command, ota_reply, board)) return false;
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
return false;
}
void MyMesh::serviceTempRadio() {
const unsigned long now = _ms->getMillis();
const bool retry_ready = !_temp_radio_retry_at
|| _temp_radio_retry_at == now || millisHasNowPassed(_temp_radio_retry_at);
const bool revert_due = _temp_radio_revert_at
&& (_temp_radio_revert_at == now || millisHasNowPassed(_temp_radio_revert_at));
if (revert_due) {
if (hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
radio_driver.setTxPower(_prefs.tx_power_dbm);
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
_temp_radio_applied = false;
saved_radio_apply_pending = false;
MESH_DEBUG_PRINTLN("Full companion restored normal radio");
} else {
_temp_radio_retry_at = futureMillis(
nextRadioApplyRetryDelay(_temp_radio_failures));
}
return;
}
if (!_temp_radio_set_at
|| (_temp_radio_set_at != now && !millisHasNowPassed(_temp_radio_set_at))
|| hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_temp_radio_freq, _temp_radio_bw, _temp_radio_sf, _temp_radio_cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
_temp_radio_set_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
_temp_radio_applied = true;
MESH_DEBUG_PRINTLN("Full companion entered TempRadio");
} else if (result == mesh::RadioParamApplyResult::BUSY) {
_temp_radio_retry_at = futureMillis(250);
} else {
// A partially applied tuple must never become persistent. Restore the
// saved Companion settings through the same bounded retry path.
_temp_radio_set_at = 0;
_temp_radio_revert_at = futureMillis(1);
_temp_radio_retry_at = 0;
saved_radio_apply_pending = true;
}
}
#endif
const char *MyMesh::getNodeName() {
return _prefs.node_name;
}
@@ -2442,6 +2610,13 @@ void MyMesh::handleCmdFrame(size_t len) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
#if defined(COMPANION_RADIO_FULL)
if (isTempRadioActive() || _temp_radio_set_at != 0
|| _temp_radio_revert_at != 0) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
#endif
int i = 1;
uint32_t freq;
memcpy(&freq, &cmd_frame[i], 4);
@@ -3275,6 +3450,14 @@ void MyMesh::handleTerminalCommand(char* command) {
while (*command == ' ') command++;
if (*command == 0) return;
#if defined(COMPANION_RADIO_FULL)
char full_reply[160];
if (handleFullOtaCommand(command, full_reply, sizeof(full_reply))) {
Serial.printf(" %s\r\n", full_reply);
return;
}
#endif
if (strncmp(command, "send ", 5) == 0) {
ContactInfo* recipient = getTerminalRecipient();
const char* text = command + 5;
@@ -3427,6 +3610,11 @@ void MyMesh::handleTerminalCommand(char* command) {
Serial.print(" advert\r\n");
Serial.print(" reset path\r\n");
Serial.print(" public <text>\r\n");
#if defined(COMPANION_RADIO_FULL)
Serial.print(" tempradio [freq,bw,sf,cr,minutes]\r\n");
Serial.print(" normalradio\r\n");
Serial.print(" ota {status|ls|announce|folder|config|...}\r\n");
#endif
Serial.print(" ver\r\n");
Serial.print(" +++MESHCORE-TERM-STOP\r\n");
} else {
@@ -3647,8 +3835,15 @@ void MyMesh::checkSerialInterface() {
}
void MyMesh::loop() {
#if defined(COMPANION_RADIO_FULL)
serviceTempRadio();
#endif
BaseChatMesh::loop();
if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()
#if defined(COMPANION_RADIO_FULL)
&& !_temp_radio_applied && _temp_radio_set_at == 0
&& _temp_radio_revert_at == 0
#endif
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) {
// A power-saving wake can enter begin() with a complete packet already
// waiting. Preserve that packet, then apply the persisted radio settings
@@ -3721,5 +3916,11 @@ bool MyMesh::hasPendingWork() const {
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at)))
|| (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry))
|| (emergency_client_repeat_packet != NULL
&& millisHasNowPassed(emergency_client_repeat_send_at));
&& millisHasNowPassed(emergency_client_repeat_send_at))
#if defined(COMPANION_RADIO_FULL)
|| (_temp_radio_set_at != 0 && millisHasNowPassed(_temp_radio_set_at))
|| (_temp_radio_revert_at != 0 && millisHasNowPassed(_temp_radio_revert_at))
|| (_temp_radio_retry_at != 0 && millisHasNowPassed(_temp_radio_retry_at))
#endif
;
}
+29
View File
@@ -149,9 +149,21 @@ public:
void handleTerminalCommand(char* command);
#endif
#if defined(COMPANION_RADIO_FULL)
// Local-only control surface shared by the USB terminal and WiFi port 5002.
// Supports bounded TempRadio windows plus the serve-only `ota ...` CLI.
bool handleFullOtaCommand(const char* command, char* reply, size_t reply_size);
#endif
int getRecentlyHeard(AdvertPath dest[], int max_num);
protected:
#if defined(COMPANION_RADIO_FULL)
bool isTempRadioActive() const override {
return _temp_radio_applied && _temp_radio_revert_at != 0
&& !millisHasNowPassed(_temp_radio_revert_at);
}
#endif
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
@@ -272,6 +284,12 @@ private:
void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat);
void cancelPendingRadioParamApply();
void servicePendingRadioParamApply();
#if defined(COMPANION_RADIO_FULL)
bool scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t timeout_mins, char* reply, size_t reply_size);
void scheduleNormalRadio(char* reply, size_t reply_size);
void serviceTempRadio();
#endif
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
@@ -320,6 +338,17 @@ private:
uint8_t command_radio_cr;
uint8_t command_radio_repeat;
unsigned long command_radio_apply_deadline;
#if defined(COMPANION_RADIO_FULL)
unsigned long _temp_radio_set_at;
unsigned long _temp_radio_revert_at;
unsigned long _temp_radio_retry_at;
float _temp_radio_freq;
float _temp_radio_bw;
uint8_t _temp_radio_sf;
uint8_t _temp_radio_cr;
uint8_t _temp_radio_failures;
bool _temp_radio_applied;
#endif
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
uint8_t app_target_ver;
+121 -5
View File
@@ -60,6 +60,12 @@ MultiSerialInterface interface_manager;
#include <helpers/ArduinoSerialInterface.h>
static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP";
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
// 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.
static const char USB_MOTA_START_TOKEN[] = "ota folder on";
#endif
ArduinoSerialInterface usb_serial_interface;
#endif
@@ -120,6 +126,12 @@ static char usb_terminal_line[MAX_TRANS_UNIT * 2 + 32];
static size_t usb_terminal_line_len = 0;
static bool usb_terminal_discard_line = false;
static bool usb_terminal_disconnect_armed = false;
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
static bool usb_mota_mode = false;
static char usb_mota_line[32];
static size_t usb_mota_line_len = 0;
static bool usb_mota_disconnect_armed = false;
#endif
static bool isUsbTerminalDataConnected() {
#if defined(RP2040_PLATFORM)
@@ -150,9 +162,100 @@ static void leaveUsbTerminalMode(bool acknowledge) {
usb_terminal_disconnect_armed = false;
}
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
static void resetUsbMotaMode() {
usb_mota_mode = false;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_disconnect_armed = false;
usb_serial_interface.setPassthroughMode(false);
}
static void leaveUsbMotaMode(bool acknowledge) {
char reply[160] = {0};
the_mesh.handleFullOtaCommand("ota folder off", reply, sizeof(reply));
if (acknowledge) {
Serial.print("\r\n");
Serial.print(reply);
Serial.print("\r\nOK - Binary mode\r\n");
}
resetUsbMotaMode();
}
static void enterUsbMotaMode() {
usb_serial_interface.setPassthroughMode(true);
usb_mota_mode = true;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
char reply[160] = {0};
if (!the_mesh.handleFullOtaCommand("ota folder on", reply, sizeof(reply))
|| strncmp(reply, "ERR", 3) == 0) {
Serial.print("\r\n");
Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode");
Serial.print("\r\n");
resetUsbMotaMode();
return;
}
Serial.print("\r\n");
Serial.print(reply);
Serial.print("\r\n");
}
static void serviceUsbMota() {
if (isUsbTerminalDataConnected()) {
usb_mota_disconnect_armed = true;
} else if (usb_mota_disconnect_armed) {
leaveUsbMotaMode(false);
return;
}
// SerialMotaSource consumes framed responses synchronously while serving a
// block. Bytes left here are host control text, notably motatool's automatic
// `ota folder off` on a clean shutdown.
while (Serial.available()) {
int value = Serial.read();
if (value < 0) break;
char c = (char)value;
if (c == '\r' || c == '\n') {
if (usb_mota_line_len == 0) continue;
usb_mota_line[usb_mota_line_len] = 0;
bool stop = strcmp(usb_mota_line, "ota folder off") == 0;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
if (stop) {
leaveUsbMotaMode(true);
return;
}
continue;
}
if (usb_mota_line_len < sizeof(usb_mota_line) - 1) {
usb_mota_line[usb_mota_line_len++] = c;
usb_mota_line[usb_mota_line_len] = 0;
} else {
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
}
}
}
#endif
static void serviceUsbTerminal() {
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
if (usb_mota_mode) {
serviceUsbMota();
return;
}
#endif
if (!the_mesh.isTerminalMode()) {
if (usb_serial_interface.takeControlSequence()) enterUsbTerminalMode();
if (usb_serial_interface.takeControlSequence()) {
enterUsbTerminalMode();
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
} else if (usb_serial_interface.takeSecondaryControlSequence()) {
enterUsbMotaMode();
#endif
}
return;
}
@@ -293,10 +396,10 @@ void halt() {
}
#endif
/* WIFI OTA CONSOLE - a tiny text CLI for OTA over WiFi. A WiFi companion has no serial text console, so
without this its OTA is only reachable through the phone app. Connect with e.g. `nc <ip> 5002` and type
/* WIFI OTA CONSOLE - a tiny text CLI for OTA over WiFi. Connect with e.g. `nc <ip> 5002` and type
`ota status` / `ota ls` / `ota announce` / ... - one client at a time, on a DEDICATED port separate from
the companion (5000) and the seeder (5001). */
the companion (5000) and the seeder (5001). Full companions also accept `tempradio ...` and
`normalradio` here so a host can run an end-to-end mOTA source without occupying the binary port. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
#ifndef OTA_CONSOLE_TCP_PORT
@@ -311,7 +414,11 @@ void halt() {
if (!ota_console_client || !ota_console_client.connected()) {
WiFiClient c = ota_console_server.available();
if (c) { ota_console_client = c; ota_console_len = 0;
ota_console_client.print("OTA console - type `ota ...` (e.g. ota status / ota ls / ota announce)\r\n> "); }
ota_console_client.print("OTA console - type `ota ...`");
#if defined(COMPANION_RADIO_FULL)
ota_console_client.print(" or `tempradio freq,bw,sf,cr,minutes`");
#endif
ota_console_client.print("\r\n> "); }
return;
}
while (ota_console_client.available()) {
@@ -320,8 +427,13 @@ void halt() {
if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair
ota_console_line[ota_console_len] = 0;
char reply[160]; reply[0] = 0;
#if defined(COMPANION_RADIO_FULL)
if (!the_mesh.handleFullOtaCommand(ota_console_line, reply, sizeof(reply)))
strcpy(reply, "supported: ota ... | tempradio ... | normalradio");
#else
if (!mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "only `ota ...` commands are supported on this console");
#endif
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
ota_console_len = 0;
} else if (ota_console_len < sizeof(ota_console_line) - 1) {
@@ -489,7 +601,11 @@ void setup() {
// add usb interface
#if defined(ENABLE_USB_INTERFACE)
#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL)
usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN, USB_MOTA_START_TOKEN);
#else
usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN);
#endif
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
#endif
+15 -2
View File
@@ -15,11 +15,20 @@ def parse_flash_size(value):
if os.environ.get("MESHCORE_ESP32_FULL_BUILD") == "1":
board = env.BoardConfig()
flash_size = parse_flash_size(board.get("upload.flash_size", "4MB"))
companion_radio_full = (
os.environ.get("MESHCORE_COMPANION_RADIO_FULL") == "1"
)
if flash_size >= 16 * 1024 * 1024:
partitions = "default_16MB.csv"
elif flash_size >= 8 * 1024 * 1024:
partitions = "default_8MB.csv"
elif flash_size >= 4 * 1024 * 1024 and companion_radio_full:
# A classic ESP32 Companion with USB, BLE, WiFi, WebConfig, and the
# mOTA seeder can exceed the largest possible pair of 4 MiB OTA
# slots. This role cannot install mOTA on itself, so give it one 3 MiB
# application slot and retain SPIFFS/coredump.
partitions = "huge_app.csv"
elif flash_size >= 4 * 1024 * 1024:
partitions = "variants/dual_ota_full_4MB.csv"
else:
@@ -28,8 +37,12 @@ if os.environ.get("MESHCORE_ESP32_FULL_BUILD") == "1":
if partitions:
board.update("build.partitions", partitions)
print(
"ESP32 FULL build: using %s for %s flash"
% (partitions, board.get("upload.flash_size", "4MB"))
"ESP32 FULL build%s: using %s for %s flash"
% (
" (Companion source-only)" if companion_radio_full else "",
partitions,
board.get("upload.flash_size", "4MB"),
)
)
else:
print(
+12 -2
View File
@@ -3,7 +3,9 @@
#if defined(ENABLE_OTA)
#include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it
#include "helpers/ota/OtaProtocol.h" // decode_adv -> the `ota neighbors` discovery table
#if !defined(OTA_SEEDER_ONLY)
#include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image
#endif
#if defined(ESP32_PLATFORM) && (defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include "helpers/esp32/WiFiOtaSeeder.h"
#endif
@@ -280,6 +282,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#if defined(ENABLE_OTA)
#if !defined(OTA_SEEDER_ONLY)
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
// so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's
// only confirmation the apply started). Reboot once that reply has actually been transmitted (the
@@ -297,6 +300,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
}
#endif
const bool ota_active = isTempRadioActive();
if (!ota_active) {
_ota_temp_was_active = false;
@@ -312,6 +316,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
// one-shot on first tick: resume an interrupted fetch left staged in flash before a reboot. Only adopt
// a PARTIAL container (continue fetching the holes); a COMPLETE one is left for manual/auto-install,
// not re-adopted at boot. requestMissing() (inside resumeStaged) drives the rest via REQ/DATA.
#if !defined(OTA_SEEDER_ONLY)
if (!_ota_resumed) {
_ota_resumed = true;
ota::OtaContext& oc = ota::ota_ctx();
@@ -320,6 +325,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
oc.manager.reset_session(); // don't auto-adopt a complete staged container on boot
}
}
#endif
ota::ota_ctx().manager.set_clock(_ms->getMillis()); // for discovery jitter/ages + the pending-query timer
ota::ota_ctx().manager.loop(); // re-request still-missing OTA blocks + fire scheduled queries
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
@@ -332,9 +338,11 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
bool in_burst = _ota_announce_count < OTA_ANNOUNCE_BURST;
uint32_t mins = oc.manager.advert_mins(); // periodic cadence in minutes; 0 = disabled (boot burst only)
if (in_burst || mins != 0) {
// To be discoverable as a source of our OWN firmware, set up flash-backed self-serve once; then the
// beacon (announce) advertises our served set and peers can QUERY + fetch it.
// Install-capable nodes add their running firmware to the served set.
// Seeder-only nodes advertise only host-provided folder entries.
#if !defined(OTA_SEEDER_ONLY)
if (!oc.serving) oc.serving = ota::ota_serve_self(oc, 0);
#endif
oc.manager.announce();
if (_ota_announce_count < 250) _ota_announce_count++;
}
@@ -345,6 +353,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
: OTA_ANNOUNCE_DISABLED_POLL_MS;
_next_ota_announce = futureMillis(gap);
}
#if !defined(OTA_SEEDER_ONLY)
{ // auto-install (once per COMPLETE fetch): only signed images, and apply_fetched enforces trust
ota::OtaContext& oc = ota::ota_ctx();
if (oc.manager.fetchState() != ota::OtaManager::COMPLETE) {
@@ -358,6 +367,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#endif
#endif
}
bool Mesh::allowPacketTransmit(const Packet* packet) const {
+26 -9
View File
@@ -8,24 +8,28 @@
void ArduinoSerialInterface::resetReceiveState() {
_state = RECV_STATE_IDLE;
_controlSequencePos = 0;
_secondaryControlSequencePos = 0;
_frame_len = 0;
rx_len = 0;
}
bool ArduinoSerialInterface::checkControlSequence(uint8_t c) {
if (_controlSequence == nullptr || _controlSequence[0] == 0) return false;
bool ArduinoSerialInterface::checkControlSequence(uint8_t c,
const char* sequence,
size_t& position,
bool& received) {
if (sequence == nullptr || sequence[0] == 0) return false;
if (c == (uint8_t)_controlSequence[_controlSequencePos]) {
_controlSequencePos++;
if (_controlSequence[_controlSequencePos] == 0) {
_controlSequencePos = 0;
_controlSequenceReceived = true;
if (c == (uint8_t)sequence[position]) {
position++;
if (sequence[position] == 0) {
position = 0;
received = true;
return true;
}
} else {
// Preserve a possible new match when this byte is also the first byte of
// the sequence (notably useful for sequences beginning with "+++").
_controlSequencePos = c == (uint8_t)_controlSequence[0] ? 1 : 0;
position = c == (uint8_t)sequence[0] ? 1 : 0;
}
return false;
}
@@ -33,6 +37,7 @@ bool ArduinoSerialInterface::checkControlSequence(uint8_t c) {
void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
_passthroughMode = enabled;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
@@ -42,14 +47,22 @@ bool ArduinoSerialInterface::takeControlSequence() {
return received;
}
bool ArduinoSerialInterface::takeSecondaryControlSequence() {
bool received = _secondaryControlSequenceReceived;
_secondaryControlSequenceReceived = false;
return received;
}
void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
@@ -90,7 +103,11 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
switch (_state) {
case RECV_STATE_IDLE:
if (checkControlSequence((uint8_t)c)) {
if (checkControlSequence((uint8_t)c, _controlSequence,
_controlSequencePos, _controlSequenceReceived)
|| checkControlSequence((uint8_t)c, _secondaryControlSequence,
_secondaryControlSequencePos,
_secondaryControlSequenceReceived)) {
// Leave any following bytes buffered for the passthrough consumer.
return 0;
}
+13 -4
View File
@@ -7,29 +7,37 @@ class ArduinoSerialInterface : public BaseSerialInterface {
bool _isEnabled;
bool _passthroughMode;
bool _controlSequenceReceived;
bool _secondaryControlSequenceReceived;
uint8_t _state;
size_t _controlSequencePos;
size_t _secondaryControlSequencePos;
uint16_t _frame_len;
uint16_t rx_len;
Stream* _serial;
const char* _controlSequence;
const char* _secondaryControlSequence;
uint8_t rx_buf[MAX_FRAME_SIZE];
bool checkControlSequence(uint8_t c);
bool checkControlSequence(uint8_t c, const char* sequence,
size_t& position, bool& received);
void resetReceiveState();
public:
ArduinoSerialInterface()
: _isEnabled(false), _passthroughMode(false),
_controlSequenceReceived(false), _state(0), _controlSequencePos(0),
_controlSequenceReceived(false), _secondaryControlSequenceReceived(false),
_state(0), _controlSequencePos(0), _secondaryControlSequencePos(0),
_frame_len(0), rx_len(0), _serial(nullptr),
_controlSequence(nullptr) {}
_controlSequence(nullptr), _secondaryControlSequence(nullptr) {}
void begin(Stream& serial, const char* controlSequence = nullptr) {
void begin(Stream& serial, const char* controlSequence = nullptr,
const char* secondaryControlSequence = nullptr) {
_serial = &serial;
_controlSequence = controlSequence;
_secondaryControlSequence = secondaryControlSequence;
_passthroughMode = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
#ifdef RAK_4631
pinMode(WB_IO2, OUTPUT);
@@ -44,6 +52,7 @@ public:
// Returns true once for each complete control sequence received while the
// binary frame parser was idle.
bool takeControlSequence();
bool takeSecondaryControlSequence();
// BaseSerialInterface methods
void enable() override;
+61 -1
View File
@@ -127,12 +127,26 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- help: list the commands in plain words (aliases in parentheses) ----
if (is_cmd(a, "help|?|h", &rest)) {
#if defined(OTA_SEEDER_ONLY)
snprintf(reply, 160,
"OTA seeder: status | stats | ls=find images | get <#> folder=capture | cancel | "
"announce | folder | config. LoRa install is disabled.");
#else
snprintf(reply, 160,
"OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | "
"announce | self | folder | cache | config | key. Use `ota ls [page]`.");
#endif
// ---- inventory dashboard: running fw (self), the one fetch session, serving state ----
} else if (*a == 0 || is_cmd(a, "status|st", &rest)) {
#if defined(OTA_SEEDER_ONLY)
uint8_t dig[4]; c.manager.servedDigest(dig);
char dighx[9]; mesh::Utils::toHex(dighx, dig, 4);
snprintf(reply, 160,
"OTA seeder | install:disabled | folder:%s | serving:%u dg=%s | target:00000000 (source only)",
c.folder_active ? c.folder_dest_info : "not connected",
(unsigned)c.manager.servedCount(), dighx);
#else
SelfFwInfo fi; bool s = ota_self_firmware(fi);
char selfhx[9]; if (s && fi.valid) mesh::Utils::toHex(selfhx, fi.body_hash, 4); else strcpy(selfhx, "?");
OtaManager::FetchState fs = c.manager.fetchState();
@@ -164,6 +178,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#endif
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
bl_state, ota_bootloader_last_rc());
#endif
#endif
// ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch,
@@ -279,12 +294,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; }
// destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up).
if (*dst == 0) {
#if defined(OTA_SEEDER_ONLY)
if (c.folder_dest)
snprintf(reply, 160, "choose a destination: `ota pull %s folder` (folder: %s)",
selstr, c.folder_dest_info);
else
strcpy(reply, "ERR no folder connected (run motatool serve --tcp)");
#else
if (c.folder_dest)
snprintf(reply, 160, "choose a destination: `ota pull %s flash` | `ota pull %s folder` (folder: %s)",
selstr, selstr, c.folder_dest_info);
else
snprintf(reply, 160, "choose a destination: `ota pull %s flash` (folder: none connected - motatool serve)",
selstr);
#endif
return true;
}
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
@@ -295,12 +318,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset
OtaStore* store; const char* dname;
if (strncmp(dst, "flash", 5) == 0) {
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
return true;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // manual install download takes priority over archiving
#endif
store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
@@ -337,9 +365,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- broadcast our tiny beacon so peers discover us. If not already serving, set up flash-backed
// self-serve first (so we're a real, fetchable source of our own running firmware). ----
} else if (is_cmd(a, "announce|adv", &rest)) {
#if defined(OTA_SEEDER_ONLY)
c.manager.announce();
snprintf(reply, 160, "OK beacon sent (serving=%u host mOTA)",
(unsigned)c.manager.servedCount());
#else
if (!c.serving) c.serving = ota_serve_self(c, 0);
c.manager.announce();
sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing");
#endif
// ---- running firmware identity (compare against a delta's base_hash) ----
} else if (is_cmd(a, "self|id", &rest)) {
@@ -366,7 +400,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path
// validates in order (payload hash -> built-for-this-firmware -> signature/trust) and returns the
// FIRST failing gate, so the operator knows exactly why it refused; it proceeds only if all pass.
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot install firmware via LoRa");
return true;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (c.sdCacheFetching()) {
strcpy(reply, "ERR SD archive capture is active; use `ota cancel` before installing");
return true;
@@ -390,7 +427,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
const char* p = rest;
if (strncmp(p, "on", 2) == 0) {
#if defined(OTA_FOLDER_SERIAL)
#if !defined(OTA_SEEDER_ONLY)
if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder
#endif
char m2[120]; c.attach_folder(m2, sizeof(m2)); c.manager.announce();
strncpy(reply, m2, 159); reply[159] = 0;
#else
@@ -398,7 +437,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#endif
} else if (strncmp(p, "off", 3) == 0) {
c.detach_folder(); c.manager.announce();
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "OK folder detached (serving nothing)");
#else
strcpy(reply, "OK folder detached (still serving own fw)");
#endif
} else { // status + list served entries (* = our own fw)
int n = snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
@@ -459,6 +502,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
else
snprintf(reply, 160, "OK SD OTA archive capture %s (saved on card)", enabled ? "on" : "off");
} else
#endif
#if defined(OTA_SEEDER_ONLY)
if (strncmp(p, "autofetch ", 10) == 0 || strncmp(p, "autoinstall ", 12) == 0) {
strcpy(reply, "ERR seeder-only build keeps autofetch and autoinstall off");
} else
#endif
if (strncmp(p, "autofetch ", 10) == 0) {
const char* v = p + 10;
@@ -499,12 +547,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
#else
#if defined(OTA_SEEDER_ONLY)
sprintf(reply, "ota config: mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops());
#else
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)",
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
#endif
#endif
}
@@ -536,6 +590,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// Raw / internal primitives (manual content load + low-level apply steps), under `ota dev ...`.
static bool handle_dev(const char* d, char* reply, OtaContext& c) {
#if defined(OTA_SEEDER_ONLY)
(void)d;
(void)c;
strcpy(reply, "ERR ota dev staging/apply is disabled on this seeder-only build");
#else
if (strncmp(d, "stage ", 6) == 0) {
uint32_t sz = parse_u32(d + 6);
if (sz == 0 || sz > OTA_SERVE_BUF_SIZE) { sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); }
@@ -646,6 +705,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
} else {
strcpy(reply, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear");
}
#endif
return true;
}
+32 -3
View File
@@ -44,9 +44,13 @@ namespace ota {
class FolderMotaStore; // pull destination over the seeder link (full type only where instantiated/used)
#ifndef OTA_SERVE_BUF_SIZE
#if defined(OTA_SEEDER_ONLY)
// Source-only Companions never accept the manual in-memory stage command.
// Keep the member valid without reserving 16 KB of nRF52840 SRAM.
#define OTA_SERVE_BUF_SIZE 1
// nRF52 self-serving streams from flash; this buffer is only for the manual `ota dev stage` helper.
// Keep it to one flash page so the OTA singleton does not consume another 16 KB of scarce SRAM.
#if defined(NRF52_PLATFORM) && (defined(OTA_FLASH_STORE) || defined(OTA_SD_STORE))
#elif defined(NRF52_PLATFORM) && (defined(OTA_FLASH_STORE) || defined(OTA_SD_STORE))
#define OTA_SERVE_BUF_SIZE 4096
#else
#define OTA_SERVE_BUF_SIZE 16384
@@ -58,7 +62,12 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl
struct OtaContext {
OtaManager manager;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
// A seeder-only node never stages an image for itself. Keep a valid default
// store object for OtaManager, while folder captures replace it with the
// host-backed FolderMotaStore for the duration of the pull.
OtaStoreRam<1> fetch_store;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
OtaStoreSdNrf52 fetch_store; // MeshTower V2: persistent SD staging, full + delta
OtaCacheSdNrf52 sd_cache; // persistent capture + source for every OTA container heard
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
@@ -131,6 +140,11 @@ struct OtaContext {
// so the deferred-reboot path (mesh loop) takes over. Caller ensures the fetch is COMPLETE. Shared by
// manual `ota applydelta` and the auto-install path.
bool apply_fetched(char* msg) {
#if defined(OTA_SEEDER_ONLY)
strncpy(msg, "refused: this build serves mOTA images but cannot install one", 96);
msg[95] = 0;
return false;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (sdCacheFetching()) {
strncpy(msg, "refused: SD archive capture owns the OTA receive slot", 96);
@@ -165,6 +179,7 @@ struct OtaContext {
#endif
if (ok) apply_pending = true;
return ok;
#endif
}
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
@@ -231,8 +246,13 @@ struct OtaContext {
folder_active = true;
_folder_link = link;
_folder_source = source;
#if defined(OTA_SEEDER_ONLY)
snprintf(msg, cap, "OK folder attached (%s) - serving %u host mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
#else
snprintf(msg, cap, "OK folder attached (%s) - serving %u mOTA total (own fw + folder)",
label ? label : "external", (unsigned)manager.servedCount());
#endif
return true;
}
@@ -379,10 +399,19 @@ struct OtaContext {
if (_fi.target_id) target_id = _fi.target_id;
if (_fi.hw_id[0]) hw = _fi.hw_id;
}
#if defined(OTA_SEEDER_ONLY)
// This role advertises only host-provided containers. A zero local target
// prevents auto-selection of firmware for the seeder itself.
target_id = 0;
#endif
manager.begin(target_id, send, ctx);
if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; }
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
manager.set_accept_full(true);
manager.set_autofetch(OtaManager::AUTOFETCH_OFF);
autoinstall = AUTOINSTALL_OFF;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
manager.set_accept_full(true);
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
#elif defined(NRF52_PLATFORM)
+1 -1
View File
@@ -43,7 +43,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp` | exact terminal control-sequence recognition across reads and binary-frame boundaries; passthrough ownership of USB input and suppression of binary output |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp` | independent terminal/seeder control-sequence recognition across reads and binary-frame boundaries; passthrough ownership of USB input and suppression of binary output |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
@@ -39,6 +39,7 @@ public:
};
static const char START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char SEEDER_TOKEN[] = "ota folder on";
TEST(SerialModeSwitch, RecognizesControlSequenceAcrossReads) {
BufferStream stream;
@@ -75,6 +76,44 @@ TEST(SerialModeSwitch, DoesNotScanInsideBinaryFrame) {
EXPECT_FALSE(interface.takeControlSequence());
}
TEST(SerialModeSwitch, RecognizesSecondaryControlSequenceSeparately) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN, SEEDER_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
stream.push("ota folder ");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_FALSE(interface.takeControlSequence());
EXPECT_FALSE(interface.takeSecondaryControlSequence());
stream.push("on\r\n");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_FALSE(interface.takeControlSequence());
EXPECT_TRUE(interface.takeSecondaryControlSequence());
EXPECT_FALSE(interface.takeSecondaryControlSequence());
EXPECT_EQ(stream.available(), 2); // trailing CRLF belongs to the seeder
}
TEST(SerialModeSwitch, DoesNotScanSecondarySequenceInsideBinaryFrame) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN, SEEDER_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const size_t token_len = strlen(SEEDER_TOKEN);
uint8_t header[] = {'<', (uint8_t)token_len, 0};
stream.push(header, sizeof(header));
stream.push(SEEDER_TOKEN);
EXPECT_EQ(interface.checkRecvFrame(frame), token_len);
EXPECT_EQ(memcmp(frame, SEEDER_TOKEN, token_len), 0);
EXPECT_FALSE(interface.takeControlSequence());
EXPECT_FALSE(interface.takeSecondaryControlSequence());
}
TEST(SerialModeSwitch, RecognizesControlSequenceAfterBinaryFrame) {
BufferStream stream;
ArduinoSerialInterface interface;
+98 -30
View File
@@ -25,6 +25,7 @@ import secrets
import shlex
import shutil
import signal
import socket
import struct
import subprocess
import sys
@@ -57,6 +58,8 @@ TEMP_RADIO_SWITCH_DELAY_SECONDS = 3
TEMP_RADIO_RETURN_MINUTES = 1
TEMP_RADIO_RETURN_MARGIN_SECONDS = 15
INSTALL_TARGET_WINDOW_MINUTES = 3
COMPANION_TERMINAL_START = "+++MESHCORE-TERM-START"
COMPANION_TERMINAL_STOP = "+++MESHCORE-TERM-STOP"
# Firmware may hold the apply reboot for up to 15 seconds while its reply
# drains. Do not interpret "still ready" as a failed install before that cap.
INSTALL_RECONCILE_WAIT_SECONDS = 20
@@ -1239,32 +1242,71 @@ def parse_temp_radio(value: str) -> tuple[float, float, int, int, int]:
def source_cli_command(args: argparse.Namespace, command_text: str, check: bool = True) -> str:
port = args.source_cli_serial or args.source_serial
if not port:
serial_port = args.source_cli_serial or args.source_serial
tcp_console = args.source_cli_tcp
if not serial_port and not tcp_console:
if check:
raise OtaError(
"a source CLI serial port is required to enable TempRadio (or use --source-already-temp)"
"a source CLI connection is required to enable TempRadio (or use --source-already-temp)"
)
return ""
command = [
args.meshcli,
"-r",
"-c", "off",
"-s", port,
"-b", str(args.source_baud),
command_text,
]
def run_once() -> str:
try:
result = run_checked(
command,
label=f"source command {command_text.split()[0]}",
timeout=30,
)
except OtaError as exc:
raise TransmissionError(f"source CLI link failed: {exc}") from exc
output = f"{result.stdout}\n{result.stderr}".strip()
if tcp_console:
host, port = split_host_port(tcp_console, 5002)
try:
with socket.create_connection((host, port), timeout=10) as connection:
connection.settimeout(10)
greeting = bytearray()
while b"\r\n> " not in greeting and len(greeting) < 4096:
chunk = connection.recv(512)
if not chunk:
break
greeting.extend(chunk)
connection.sendall(command_text.encode("utf-8") + b"\r\n")
response = bytearray()
while b"\r\n> " not in response and len(response) < 4096:
chunk = connection.recv(512)
if not chunk:
break
response.extend(chunk)
except (OSError, UnicodeError) as exc:
raise TransmissionError(f"source TCP console failed: {exc}") from exc
text = response.decode("utf-8", "replace")
match = re.search(r"(?:^|\r?\n)\s*->\s*(.*?)\r?\n>\s*$", text, re.DOTALL)
if not match:
raise TransmissionError(
f"source TCP console returned no command reply: {text.strip() or 'no output'}"
)
output = match.group(1).strip()
else:
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.
wire_command = (
f"{COMPANION_TERMINAL_START}\r"
f"{command_text}\r"
f"{COMPANION_TERMINAL_STOP}"
)
command = [
args.meshcli,
"-r",
"-c", "off",
"-s", serial_port,
"-b", str(args.source_baud),
wire_command,
]
try:
result = run_checked(
command,
label=f"source command {command_text.split()[0]}",
timeout=30,
)
except OtaError as exc:
raise TransmissionError(f"source CLI link failed: {exc}") from exc
output = f"{result.stdout}\n{result.stderr}".strip()
lowered = output.lower()
if "error" in lowered or "unknown command" in lowered or "err " in lowered:
raise OtaError(f"source rejected {command_text!r}: {output}")
@@ -1288,14 +1330,31 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool
def preflight_source_cli(args: argparse.Namespace) -> None:
if not (args.source_serial or args.source_cli_serial):
if not (args.source_serial or args.source_cli_serial or args.source_cli_tcp):
return
output = source_cli_command(args, "ota status")
if "OTA |" not in output or "target:" not in output:
def valid_status(value: str) -> bool:
full_seeder = "OTA seeder" in value and "install:disabled" in value
return full_seeder or ("OTA |" in value and "target:" in value)
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.
args.source_companion_terminal = False
output = source_cli_command(args, "ota status", check=False)
if not valid_status(output):
args.source_companion_terminal = True
output = source_cli_command(args, "ota status")
else:
output = source_cli_command(args, "ota status")
if not valid_status(output):
raise OtaError(
"OTA source did not return a valid `ota status`. Use an OTA-enabled "
"repeater/FULL raw text CLI; Companion USB is a binary API port and "
"cannot be the serial seeder."
"repeater raw text CLI, nRF52 full Companion USB port, or "
"companion_radio_full TCP console."
)
@@ -1793,10 +1852,15 @@ def build_parser() -> argparse.ArgumentParser:
source = parser.add_mutually_exclusive_group()
source.add_argument("--source-serial", metavar="PORT")
source.add_argument("--source-tcp", metavar="HOST[:PORT]")
parser.add_argument(
source_cli = parser.add_mutually_exclusive_group()
source_cli.add_argument(
"--source-cli-serial", metavar="PORT",
help="local text-CLI port for a TCP seeder source",
)
source_cli.add_argument(
"--source-cli-tcp", metavar="HOST[:PORT]",
help="companion_radio_full text console for a TCP seeder source (default port 5002)",
)
parser.add_argument("--controller-baud", type=int, default=115200)
parser.add_argument("--source-baud", type=int, default=115200)
parser.add_argument(
@@ -1890,10 +1954,14 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
parser.error("a controller connection is required")
if not any((args.source_serial, args.source_tcp)):
parser.error("a source seeder connection is required")
if args.source_serial and args.source_cli_serial:
parser.error("--source-cli-serial is only used with --source-tcp")
if args.source_tcp and not (args.source_cli_serial or args.source_already_temp):
parser.error("--source-tcp also needs --source-cli-serial or --source-already-temp")
if args.source_serial and (args.source_cli_serial or args.source_cli_tcp):
parser.error("--source-cli-serial/--source-cli-tcp are only used with --source-tcp")
if args.source_tcp and not (
args.source_cli_serial or args.source_cli_tcp or args.source_already_temp
):
parser.error(
"--source-tcp also needs --source-cli-serial, --source-cli-tcp, or --source-already-temp"
)
if args.controller_serial and args.source_serial:
if os.path.abspath(args.controller_serial) == os.path.abspath(args.source_serial):
parser.error("controller and source must be separate nodes/serial ports")
+103
View File
@@ -193,6 +193,109 @@ class FormatTests(unittest.TestCase):
ota.read_bounded_file(path, 3, "mOTA file")
class SourceCliTests(unittest.TestCase):
def test_full_companion_tcp_console_command(self) -> None:
connection = mock.MagicMock()
connection.__enter__.return_value = connection
connection.recv.side_effect = [
b"OTA console - type `ota ...`\r\n> ",
b" -> OTA seeder | install:disabled | serving:1\r\n> ",
]
args = argparse.Namespace(
source_cli_serial=None,
source_serial=None,
source_cli_tcp="192.0.2.10",
meshcli="meshcli",
source_baud=115200,
)
with mock.patch.object(
ota.socket, "create_connection", return_value=connection
) as create_connection:
output = ota.source_cli_command(args, "ota status")
create_connection.assert_called_once_with(("192.0.2.10", 5002), timeout=10)
connection.sendall.assert_called_once_with(b"ota status\r\n")
self.assertEqual(output, "OTA seeder | install:disabled | serving:1")
def test_full_companion_tcp_source_arguments_validate(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
"release.mota", "remote",
"--controller-serial", "/dev/controller",
"--source-tcp", "192.0.2.10:5001",
"--source-cli-tcp", "192.0.2.10:5002",
])
ota.validate_args(args, parser)
def test_source_preflight_accepts_seeder_only_status(self) -> None:
args = argparse.Namespace(
source_serial=None,
source_cli_serial=None,
source_cli_tcp="192.0.2.10:5002",
)
with mock.patch.object(
ota,
"source_cli_command",
return_value="OTA seeder | install:disabled | target:00000000",
) as source_command:
ota.preflight_source_cli(args)
source_command.assert_called_once_with(args, "ota status")
def test_serial_preflight_falls_back_to_companion_terminal(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",
side_effect=(
"",
"OTA seeder | install:disabled | target:00000000",
),
) as source_command:
ota.preflight_source_cli(args)
self.assertTrue(args.source_companion_terminal)
self.assertEqual(
source_command.call_args_list,
[
mock.call(args, "ota status", check=False),
mock.call(args, "ota status"),
],
)
def test_serial_companion_command_is_wrapped_in_terminal_tokens(self) -> None:
args = argparse.Namespace(
source_cli_serial=None,
source_serial="/dev/source",
source_cli_tcp=None,
source_companion_terminal=True,
meshcli="meshcli",
source_baud=115200,
)
completed = subprocess.CompletedProcess(
args=[], returncode=0,
stdout="OK - temp params for 120 mins",
stderr="",
)
with mock.patch.object(ota, "run_checked", return_value=completed) as run:
output = ota.source_cli_command(
args, "tempradio 909.95,250,7,5,120"
)
wire_command = run.call_args.args[0][-1]
self.assertEqual(
wire_command,
"+++MESHCORE-TERM-START\r"
"tempradio 909.95,250,7,5,120\r"
"+++MESHCORE-TERM-STOP",
)
self.assertIn("OK - temp params", output)
class CompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
self.base_image = firmware(b"old" * 2000, VERSION_OLD)