mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-08-29 10:48:19 +00:00
Add full Companion OTA source builds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user