feat: add Bluetooth-controlled LoRa OTA

Add the framed BLE mOTA transport and companion control path, complete the full-profile build and runtime fixes, and document and test the OTA workflow.
This commit is contained in:
mikecarper
2026-08-27 01:22:18 -07:00
parent bd3a94bddb
commit 69ded9d6c7
79 changed files with 3178 additions and 304 deletions
+243 -146
View File
@@ -46,7 +46,10 @@ RADIO_SF_OVERRIDE="$USA_CASCADIA_FALLBACK_SF"
RADIO_CR_OVERRIDE="$USA_CASCADIA_FALLBACK_CR"
FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-cascade}"
BATCH_BUILD_MODE=0
OPTION3_BUILD_WORKERS="${OPTION3_BUILD_WORKERS:-2}"
# PlatformIO shares and cleans .pio/build across environments in this checkout.
# Keep target-level builds strictly single-process; OPTION3_PIO_JOBS only
# controls compiler parallelism inside that one PlatformIO process.
OPTION3_BUILD_WORKERS=1
OPTION3_PIO_JOBS="${OPTION3_PIO_JOBS:-8}"
PROFILE_BUILD_WORKERS=1
PIO_BUILD_JOBS_OVERRIDE=""
@@ -99,9 +102,9 @@ Commands:
help|usage|-h|--help: Shows this message.
list|-l: List firmwares available to build.
build-firmware <target>: Build the firmware for the given build target.
build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases remain available as explicit builds.
build-firmwares-logging-matrix: Build the canonical standard, logging, unified FULL ESP32 USB+WiFi, and FULL logging fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. Full Companion targets provide runtime USB logging through a dedicated port or an input-capable single-TTY terminal.
build-companion-firmwares-logging-matrix: Build canonical Companion targets in each applicable standard, MQTT, and expanded FULL profile. Full Companion replaces separate USB, BLE, WiFi, and USB-logging artifacts where an exact combined recipe exists.
build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases and Terminal Chat targets replaced by Full Companion remain available as explicit builds.
build-firmwares-logging-matrix: Build canonical standard artifacts with merged runtime USB logging plus unified FULL ESP32 USB+WiFi and FULL fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. KISS, BLE-only Companion, and constrained LoRa-OTA receiver contracts do not gain plaintext USB logging.
build-companion-firmwares-logging-matrix: Build canonical Companion targets with merged runtime USB logging where the transport is safe, plus applicable MQTT and expanded FULL profiles. Full Companion replaces separate USB, BLE, WiFi, Terminal Chat, and USB-logging artifacts where an exact combined recipe exists.
build-full-esp32-firmwares: Build feature-complete ESP32 profiles with up to 254 neighbors, USB packet logging, WiFi MQTT where supported, LoRa OTA, and expanded dual-OTA partitions.
build-full-esp32-logging-firmwares: Build only the FULL USB-logging fallback for targets without a matching WiFi MQTT environment.
build-matching-firmwares <build-match-spec>: Build all firmwares for build targets containing the string given for <build-match-spec>.
@@ -187,8 +190,7 @@ Environment Variables:
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.
OPTION3_PIO_JOBS=8: Compiler jobs inside the single active PlatformIO process.
Examples:
Build without debug logging:
@@ -1780,16 +1782,6 @@ filter_out_kiss_modem_targets() {
RESOLVED_BUILD_TARGETS=("${filtered_targets[@]}")
}
filter_out_bluetooth_targets() {
local target
for target in "$@"; do
if ! is_bluetooth_target "$target"; then
printf '%s\n' "$target"
fi
done
}
is_lora_ota_only_target() {
local target_lc=${1,,}
[[ "$target_lc" == *lora_ota* ]]
@@ -1813,16 +1805,6 @@ is_rak_i2c_voltage_monitor_ota_target() {
esac
}
filter_out_lora_ota_only_targets() {
local target
for target in "$@"; do
if ! is_lora_ota_only_target "$target"; then
printf '%s\n' "$target"
fi
done
}
is_logging_size_constrained_target() {
case "$1" in
Tiny_Relay_repeater|RAK_3x72_repeater|wio-e5_repeater|wio-e5-repeater_bridge_rs232|wio-e5-mini_companion_radio_usb|wio-e5-mini_repeater|wio-e5-mini_sensor)
@@ -1834,16 +1816,6 @@ is_logging_size_constrained_target() {
esac
}
filter_out_logging_size_constrained_targets() {
local target
for target in "$@"; do
if ! is_logging_size_constrained_target "$target"; then
printf '%s\n' "$target"
fi
done
}
prompt_for_kiss_modem_build_policy() {
local kiss_count=0
local target
@@ -2203,6 +2175,51 @@ apply_debug_overrides() {
esac
}
uses_merged_standard_usb_logging() {
local env_name=$1
# These profiles either own Serial for framed traffic, deliberately trade
# logging for OTA space, or provide their own logging contract. Keep those
# contracts unchanged.
if is_kiss_modem_target "$env_name" \
|| is_bluetooth_target "$env_name" \
|| is_lora_ota_only_target "$env_name" \
|| is_mqtt_bridge_target "$env_name" \
|| is_companion_radio_full_target "$env_name"; then
return 1
fi
case "${env_name,,}" in
*companion*|*comp_radio*|*repeater*|*repeatr*|*room_server*|*room_svr*|\
*sensor*|*terminal_chat*)
return 0
;;
*)
return 1
;;
esac
}
apply_merged_standard_usb_logging_profile() {
local env_name=$1
uses_merged_standard_usb_logging "$env_name" || return 0
# Explicit diagnostic overrides retain their documented meaning. Canonical
# builds otherwise compile packet/debug output into the ordinary artifact;
# get/set usb.logging controls the live Serial stream at runtime.
if [ "${DISABLE_DEBUG:-0}" = "1" ]; then
return 0
fi
if [ "${PACKET_LOGGING_OVERRIDE,,}" != "off" ]; then
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1 -DMESH_USB_LOGGING_MERGED=1"
fi
if [ "${MESHDEBUG_OVERRIDE,,}" != "off" ] \
&& ! is_logging_size_constrained_target "$env_name"; then
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_DEBUG=1"
fi
}
disable_usb_logging_for_mqtt() {
local env_name=$1
@@ -2554,6 +2571,8 @@ declare_build_capability_contract() {
record_build_expectation "web.webconfig" "start webconfig"
else
record_build_expectation "companion.usb_mota_source" "ota folder on"
record_build_expectation "companion.ble_mota_source" \
"Bluetooth mOTA source"
fi
elif [ "$env_platform" = "ESP32_PLATFORM" ] \
&& is_esp32_companion_build "$env_name" \
@@ -2983,7 +3002,7 @@ apply_companion_radio_full_profile() {
# preamble. CDC 1 is a write-only plaintext packet/debug logging stream;
# BLE remains an independent Companion link.
append_platformio_build_unflags "-UOTA_FOLDER_SERIAL"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1"
if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/"; then
append_platformio_build_src_filter "+<helpers/ota/*.cpp>"
@@ -3299,16 +3318,9 @@ get_firmware_filename() {
local firmware_version_string=$2
local filename_infix=$FIRMWARE_FILENAME_INFIX
if [ -z "$filename_infix" ] \
&& [ "${PACKET_LOGGING_OVERRIDE,,}" == "on" ] \
&& [ "${MQTT_BRIDGE_OVERRIDE,,}" != "on" ] \
&& ! is_mqtt_bridge_target "$env_name"; then
filename_infix="logging"
fi
# Make LoRa-OTA artifacts as obvious as logging artifacts without changing
# the PlatformIO environment name or the stable MOTA target identity. FULL
# artifacts retain their profile marker as well as the required OTA marker.
# Make LoRa-OTA artifacts obvious without changing the PlatformIO environment
# name or the stable MOTA target identity. FULL artifacts retain their
# profile marker as well as the required OTA marker.
if [ "$ESP32_FULL_BUILD" = "1" ] && is_lora_ota_build "$env_name"; then
if [ "$filename_infix" = "full-logging" ]; then
filename_infix="full-logging-ota"
@@ -3489,6 +3501,7 @@ build_firmware() {
apply_debug_overrides "$env_name"
apply_mqtt_bridge_override
disable_usb_logging_for_mqtt "$env_name"
apply_merged_standard_usb_logging_profile "$env_name"
apply_lora_ota_override "$env_name"
apply_logical_ota_tuning_flags "$env_name" "$pio_env_name"
apply_companion_radio_full_profile "$env_name" "$pio_env_name"
@@ -3669,6 +3682,9 @@ get_nrf52_full_companion_replacement() {
*companion_radio_ble*)
full_env=${env_name/companion_radio_ble/companion_radio_full}
;;
*companion_radio_ethernet*)
full_env=${env_name/companion_radio_ethernet/companion_radio_full}
;;
*)
return 1
;;
@@ -3686,7 +3702,8 @@ get_esp32_full_companion_replacement() {
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] || return 1
case "$env_name" in
*companion_radio_wifi_mqtt*) return 1 ;;
*companion_radio_usb*|*companion_radio_ble*|*companion_radio_wifi*) ;;
*companion_radio_usb*|*companion_radio_ble*|*companion_radio_wifi*|\
*companion_radio_serial*|*companion_radio_ethernet*) ;;
*) return 1 ;;
esac
@@ -3718,6 +3735,12 @@ get_esp32_full_companion_replacement() {
*companion_radio_wifi*)
full_env=${source_env/companion_radio_wifi/companion_radio_full}
;;
*companion_radio_serial*)
full_env=${source_env/companion_radio_serial/companion_radio_full}
;;
*companion_radio_ethernet*)
full_env=${source_env/companion_radio_ethernet/companion_radio_full}
;;
*)
return 1
;;
@@ -3734,8 +3757,148 @@ get_full_companion_replacement() {
|| get_esp32_full_companion_replacement "$1"
}
is_companion_transport_replaced_by_full() {
get_full_companion_replacement "$1" >/dev/null
get_terminal_chat_full_companion_replacement() {
local source_env=$1
local env_name=${source_env,,}
local full_env=""
case "${PIO_ENV_PLATFORM_BY_NAME[$source_env]:-}" in
ESP32_PLATFORM|NRF52_PLATFORM) ;;
*) return 1 ;;
esac
# Most Terminal Chat and Full Companion targets share an exact hardware
# prefix. These exceptions use the canonical runtime-configurable Full name
# instead of the older revision/FEM-specific spelling.
case "$env_name" in
heltec_v4_terminal_chat)
full_env=heltec_v4_2_v4_3_companion_radio_full_femon
;;
heltec_v4_tft_terminal_chat)
full_env=heltec_v4_tft_companion_radio_full_femon
;;
heltec_tracker_v2_terminal_chat)
full_env=heltec_tracker_v2_companion_radio_full_femon
;;
*terminal_chat*)
full_env=${source_env/terminal_chat/companion_radio_full}
;;
*)
return 1
;;
esac
is_companion_radio_full_target "$full_env" || return 1
printf '%s\n' "$full_env"
}
get_terminal_chat_usb_companion_replacement() {
local source_env=$1
local env_name=${source_env,,}
local usb_env=""
case "$env_name" in
generic_espnow_terminal_chat)
usb_env=Generic_ESPNOW_comp_radio_usb
;;
*terminal_chat*)
usb_env=${source_env/terminal_chat/companion_radio_usb}
;;
*)
return 1
;;
esac
[ -n "${PIO_ENV_PLATFORM_BY_NAME[$usb_env]+x}" ] || return 1
[ "${PIO_ENV_PLATFORM_BY_NAME[$usb_env]:-}" = "${PIO_ENV_PLATFORM_BY_NAME[$source_env]:-}" ] \
|| return 1
[ "${PIO_ENV_BOARD_BY_NAME[$usb_env]:-}" = "${PIO_ENV_BOARD_BY_NAME[$source_env]:-}" ] \
|| return 1
printf '%s\n' "$usb_env"
}
get_terminal_chat_companion_replacement() {
get_terminal_chat_full_companion_replacement "$1" 2>/dev/null \
|| get_terminal_chat_usb_companion_replacement "$1"
}
get_combined_usb_ble_companion_replacement() {
case "${1,,}" in
heltec_e290_companion_ble|heltec_e290_companion_usb)
printf '%s\n' Heltec_E290_companion_usb_ble
;;
heltec_t190_companion_radio_ble_|heltec_t190_companion_radio_usb_)
printf '%s\n' Heltec_T190_companion_radio_usb_ble_
;;
*)
return 1
;;
esac
}
get_merged_rs232_repeater_replacement() {
case "${1,,}" in
heltec_t096_repeater_bridge_rs232)
printf '%s\n' Heltec_t096_repeater
;;
heltec_t096_repeater_bridge_rs232_lora_ota_no_external_sensors)
printf '%s\n' Heltec_t096_repeater_lora_ota_no_external_sensors
;;
rak_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|\
rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors)
printf '%s\n' RAK_4631_repeater_lora_ota_no_external_sensors
;;
rak_4631_repeater_bridge_rs232_serial1|\
rak_4631_repeater_bridge_rs232_serial2)
printf '%s\n' RAK_4631_repeater
;;
promicro_repeater_bridge_rs232_serial1)
printf '%s\n' ProMicro_repeater
;;
heltec_t114_without_display_repeater_bridge_rs232)
printf '%s\n' Heltec_t114_without_display_repeater
;;
heltec_t114_repeater_bridge_rs232)
printf '%s\n' Heltec_t114_repeater
;;
rak_3112_repeater_bridge_rs232)
printf '%s\n' RAK_3112_repeater
;;
rak_11310_repeater_bridge_rs232)
printf '%s\n' RAK_11310_repeater
;;
waveshare_rp2040_lora_repeater_bridge_rs232)
printf '%s\n' waveshare_rp2040_lora_repeater
;;
solarxiao_30s_repeater_bridge_rs232)
printf '%s\n' solarxiao_30S_repeater
;;
solarxiao_33s_repeater_bridge_rs232)
printf '%s\n' solarxiao_33S_repeater
;;
heltec_v3_repeater_bridge_rs232)
printf '%s\n' Heltec_v3_repeater
;;
heltec_wsl3_repeater_bridge_rs232)
printf '%s\n' Heltec_WSL3_repeater
;;
lilygo_tlora_v2_1_1_6_repeater_bridge_rs232)
printf '%s\n' LilyGo_TLora_V2_1_1_6_repeater
;;
*)
# Wio-E5 intentionally remains separate: its normal image has only 916
# bytes free, and the measured combined image exceeds its 240 KiB app
# partition by 2,192 bytes unless the normal USB/MQTT host CLI is removed.
return 1
;;
esac
}
is_firmware_role_replaced_by_canonical_artifact() {
get_full_companion_replacement "$1" >/dev/null 2>&1 \
|| get_terminal_chat_companion_replacement "$1" >/dev/null \
|| get_combined_usb_ble_companion_replacement "$1" >/dev/null \
|| get_merged_rs232_repeater_replacement "$1" >/dev/null
}
is_runtime_setting_alias_target() {
@@ -3745,16 +3908,22 @@ is_runtime_setting_alias_target() {
|| is_exact_companion_recipe_alias_target "$1"; then
return 0
fi
case "${1,,}" in
ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_full)
return 0
;;
esac
return 1
}
is_redundant_bulk_build_target() {
# Keep every legacy name available to `build-firmware` and
# `build-matching-firmwares`, but do not republish binaries that differ only
# by a saved/default setting or by an attached transport already supplied by
# a dual-CDC Full Companion.
# by a saved/default setting, or roles already supplied by Full Companion.
# Its text terminal supersedes standalone Terminal Chat on the same exact
# hardware, in addition to its combined attached transports.
if is_runtime_setting_alias_target "$1" \
|| is_companion_transport_replaced_by_full "$1"; then
|| is_firmware_role_replaced_by_canonical_artifact "$1"; then
return 0
fi
return 1
@@ -4158,7 +4327,9 @@ run_logged_build_targets() {
local build_status=0
local overall_status=0
local preserved_log=0
local worker_limit=${PROFILE_BUILD_WORKERS:-1}
# Never overlap PlatformIO processes in this checkout: environments share
# .pio/build and can clean one another's objects.
local worker_limit=1
local pio_job_limit=${OPTION3_PIO_JOBS:-8}
local next_index=0
local candidate_index
@@ -4501,10 +4672,6 @@ run_logging_matrix_build_targets() {
local targets=("$@")
local target
local standard_targets=()
local logging_source_targets=()
local logging_targets=()
local filtered_logging_targets=()
local constrained_logging_targets=()
local original_meshdebug_override=$MESHDEBUG_OVERRIDE
local original_packet_logging_override=$PACKET_LOGGING_OVERRIDE
local original_mqtt_bridge_override=$MQTT_BRIDGE_OVERRIDE
@@ -4512,12 +4679,9 @@ run_logging_matrix_build_targets() {
local original_firmware_filename_infix=$FIRMWARE_FILENAME_INFIX
local original_esp32_full_build=$ESP32_FULL_BUILD
local original_profile_build_workers=$PROFILE_BUILD_WORKERS
local bluetooth_skip_count=0
local lora_ota_only_skip_count=0
local full_only_standard_skip_count=0
local full_companion_logging_skip_count=0
local full_profile_logging_skip_count=0
local logging_target_count=0
local merged_usb_logging_count=0
local constrained_merged_logging_count=0
local build_status=0
local pass_status=0
@@ -4527,27 +4691,35 @@ run_logging_matrix_build_targets() {
fi
LOGGING_MATRIX_FAILURES=()
PROFILE_BUILD_WORKERS=$OPTION3_BUILD_WORKERS
echo "Option 3 parallelism: ${PROFILE_BUILD_WORKERS} target build(s), ${OPTION3_PIO_JOBS} PlatformIO job(s) per target."
echo "Option 3 PlatformIO policy: one target build at a time, ${OPTION3_PIO_JOBS} compiler job(s) inside that process."
for target in "${targets[@]}"; do
if is_mqtt_bridge_target "$target"; then
continue
fi
logging_source_targets+=("$target")
if requires_esp32_full_cli_profile "$target"; then
full_only_standard_skip_count=$((full_only_standard_skip_count + 1))
else
standard_targets+=("$target")
if uses_merged_standard_usb_logging "$target"; then
merged_usb_logging_count=$((merged_usb_logging_count + 1))
if is_logging_size_constrained_target "$target"; then
constrained_merged_logging_count=$((constrained_merged_logging_count + 1))
fi
fi
fi
done
echo "Profile 1/3: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off."
echo "Profile 1/2: building ${#standard_targets[@]} standard target(s); ${merged_usb_logging_count} embed runtime-controlled USB logging in the ordinary artifact."
if [ "$constrained_merged_logging_count" -gt 0 ]; then
echo "Keeping verbose MESH_DEBUG off for ${constrained_merged_logging_count} size-constrained STM32 target(s); packet logging remains available at runtime."
fi
if [ "$full_only_standard_skip_count" -gt 0 ]; then
echo "Deferring ${full_only_standard_skip_count} ESP32 ESP-NOW target(s) to their FULL logging fallback; its persistent USB gate also provides normal output-off operation."
fi
ESP32_FULL_BUILD=0
MESHDEBUG_OVERRIDE="off"
PACKET_LOGGING_OVERRIDE="off"
MESHDEBUG_OVERRIDE=""
PACKET_LOGGING_OVERRIDE=""
MQTT_BRIDGE_OVERRIDE="off"
FIRMWARE_FILENAME_INFIX=""
if [ ${#standard_targets[@]} -gt 0 ]; then
@@ -4557,87 +4729,12 @@ run_logging_matrix_build_targets() {
if [ "$pass_status" -ne 0 ]; then build_status=1; fi
fi
mapfile -t logging_targets < <(filter_out_bluetooth_targets "${logging_source_targets[@]}")
bluetooth_skip_count=$((${#logging_source_targets[@]} - ${#logging_targets[@]}))
if [ "$bluetooth_skip_count" -gt 0 ]; then
echo "Skipping ${bluetooth_skip_count} Bluetooth target(s) for logging-on pass."
fi
lora_ota_only_skip_count=0
for target in "${logging_targets[@]}"; do
if is_lora_ota_only_target "$target"; then
lora_ota_only_skip_count=$((lora_ota_only_skip_count + 1))
fi
done
mapfile -t logging_targets < <(filter_out_lora_ota_only_targets "${logging_targets[@]}")
if [ "$lora_ota_only_skip_count" -gt 0 ]; then
echo "Skipping ${lora_ota_only_skip_count} LoRa-OTA-only target(s) for logging-on pass because logging disables LoRa OTA."
fi
filtered_logging_targets=()
for target in "${logging_targets[@]}"; do
if is_companion_radio_full_target "$target"; then
full_companion_logging_skip_count=$((full_companion_logging_skip_count + 1))
elif has_esp32_full_profile "$target"; then
full_profile_logging_skip_count=$((full_profile_logging_skip_count + 1))
else
filtered_logging_targets+=("$target")
fi
done
logging_targets=("${filtered_logging_targets[@]}")
if [ "$full_profile_logging_skip_count" -gt 0 ]; then
echo "Deferring ${full_profile_logging_skip_count} ESP32 target(s) to the unified FULL/fallback pass; their separate standard logging artifacts would be redundant."
fi
if [ "$full_companion_logging_skip_count" -gt 0 ]; then
echo "Skipping ${full_companion_logging_skip_count} Full Companion target(s) for the separate logging-on pass; each Full image provides persistent runtime logging through either a dedicated CDC port or its input-capable single-TTY terminal."
fi
for target in "${logging_targets[@]}"; do
if is_logging_size_constrained_target "$target"; then
constrained_logging_targets+=("$target")
fi
done
mapfile -t logging_targets < <(filter_out_logging_size_constrained_targets "${logging_targets[@]}")
logging_target_count=$((${#logging_targets[@]} + ${#constrained_logging_targets[@]}))
if [ "$logging_target_count" -gt 0 ]; then
echo "Profile 2/3: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off."
echo "Logging-on artifacts use filename form: name-logging-version"
else
echo "No non-Bluetooth targets remain for logging-on pass."
fi
if [ ${#logging_targets[@]} -gt 0 ]; then
MESHDEBUG_OVERRIDE="on"
PACKET_LOGGING_OVERRIDE="on"
MQTT_BRIDGE_OVERRIDE="off"
FIRMWARE_FILENAME_INFIX="logging"
run_logged_build_targets "${logging_targets[@]}"
pass_status=$?
if [ "$pass_status" -eq 130 ]; then return 130; fi
if [ "$pass_status" -ne 0 ]; then build_status=1; fi
fi
if [ ${#constrained_logging_targets[@]} -gt 0 ]; then
echo "Building ${#constrained_logging_targets[@]} size-constrained STM32 target(s) with packet logging on and MESH_DEBUG off to fit flash."
MESHDEBUG_OVERRIDE="off"
PACKET_LOGGING_OVERRIDE="on"
MQTT_BRIDGE_OVERRIDE="off"
FIRMWARE_FILENAME_INFIX="logging"
run_logged_build_targets "${constrained_logging_targets[@]}"
pass_status=$?
if [ "$pass_status" -eq 130 ]; then return 130; fi
if [ "$pass_status" -ne 0 ]; then build_status=1; fi
fi
run_full_esp32_profile "Profile 3/3 unified FULL" "unified" "${targets[@]}"
run_full_esp32_profile "FULL unified pass" "unified" "${targets[@]}"
pass_status=$?
if [ "$pass_status" -eq 130 ]; then return 130; fi
if [ "$pass_status" -ne 0 ]; then build_status=1; fi
run_full_esp32_profile "Profile 3/3 logging fallback" "fallback" "${targets[@]}"
run_full_esp32_profile "FULL logging fallback pass" "fallback" "${targets[@]}"
pass_status=$?
if [ "$pass_status" -eq 130 ]; then return 130; fi
if [ "$pass_status" -ne 0 ]; then build_status=1; fi
@@ -4863,7 +4960,7 @@ main() {
echo "Skipping separate debug and MQTT prompts; FULL everything enables USB logging and WiFi MQTT where the hardware supports it."
elif is_automatic_profile_command "${SELECTED_COMMAND_ARGS[0]}"; then
if is_logging_matrix_command "${SELECTED_COMMAND_ARGS[0]}"; then
echo "Skipping debug and MQTT prompts; this action builds standard, logging, and unified FULL profiles automatically."
echo "Skipping debug and MQTT prompts; this action builds standard artifacts with merged runtime USB logging and unified FULL profiles automatically."
elif is_full_esp32_logging_command "${SELECTED_COMMAND_ARGS[0]}"; then
echo "Skipping debug and MQTT prompts; this action builds only logging fallbacks for FULL targets without WiFi MQTT."
else
+5 -5
View File
@@ -431,8 +431,8 @@ meaning can change when the service reorders or adds presets.
| Build profile | WiFi/MQTT behavior |
|---|---|
| Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. |
| Logging | Enables USB/debug packet logging and disables the MQTT bridge. CommonCLI roles persist `get/set usb.logging`; logging output itself is not a direct MQTT uplink. |
| Standard | Uses the selected target's role. Where USB is a safe plaintext console, the same artifact embeds debug/packet logging behind persistent `get/set usb.logging`. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. |
| Legacy logging | No separate artifact is emitted; USB logging is part of the ordinary image. Logging output itself is not a direct MQTT uplink. |
| MQTT | Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. Non-companion ESP32 MQTT observers always use FULL expanded partitions. |
| FULL ESP32 USB + WiFi | Uses the board's MQTT target with USB packet logging and direct WiFi MQTT together, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. `get/set logging.output off\|usb\|wifi\|both` persists the active paths. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. |
| FULL ESP32 logging fallback | Uses the board's non-MQTT target only when no matching WiFi MQTT environment exists. It keeps debug and packet logging, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. Persistent `usb.logging off` also provides normal output-off operation, so ESP-NOW FULL roles need no second non-logging image. |
@@ -445,9 +445,9 @@ persistent MQTT discovery state leaves insufficient internal-DRAM margin at 254.
The interactive Option 1 **FULL everything** choice and the standalone FULL
command select the unified USB + WiFi image when a matching MQTT target exists;
otherwise they select the logging fallback. The build matrix no longer emits a
separate standard logging image or non-MQTT FULL twin for a role covered by the
unified image. All FULL profiles include LoRa OTA, WebConfig where supported,
otherwise they select the logging fallback. The build matrix no longer emits
any separate standard logging image, or a non-MQTT FULL twin for a role covered
by the unified image. All FULL profiles include LoRa OTA, WebConfig where supported,
up to 254 neighbors, and expanded dual-OTA partitions. Target-specific
internal-DRAM limits still apply.
+91 -6
View File
@@ -53,6 +53,7 @@
full: "Full Companion transports",
ble: "Bluetooth LE",
usb: "USB",
"usb-ble": "USB + Bluetooth LE",
wifi: "Wi-Fi",
serial: "Serial / UART",
ethernet: "Ethernet",
@@ -109,6 +110,7 @@
// Exact aliases of the unsuffixed Heltec V4 USB/BLE recipes.
"heltec_v4_companion_radio_usb_femon",
"heltec_v4_companion_radio_ble_femon",
"ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_full",
]);
let pickerInstanceCount = 0;
@@ -273,6 +275,7 @@
const value = String(tail).toLowerCase().replace(/^[_-]+/, "");
if (role === "companion") {
if (/^full(?:$|[_-])/.test(value)) return "full";
if (/^usb_ble(?:$|[_-])/.test(value)) return "usb-ble";
if (/^ble(?:$|[_-])/.test(value)) return "ble";
if (/^usb(?:$|[_-])/.test(value)) return "usb";
if (/^wifi(?:$|[_-])/.test(value)) return "wifi";
@@ -291,7 +294,7 @@
let value = String(tail);
if (role === "companion") {
value = value.replace(
/^(?:full|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i,
/^(?:full|usb_ble|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i,
" "
);
// Power saving and controllable FEM gain are persisted settings. Legacy
@@ -414,13 +417,69 @@
}).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
const combinedUsbBleKeys = new Set((profiles || []).filter(
function (profile) {
return profile.role === "companion" && profile.mode === "usb-ble";
}
).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
const terminalReplacementKeys = new Set((profiles || []).filter(
function (profile) {
return profile.role === "companion" &&
(profile.mode === "full" || profile.mode === "usb" ||
profile.mode === "usb-ble");
}
).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
const targets = new Set((profiles || []).map(function (profile) {
return String(profile.target || "").toLowerCase();
}));
const mergedRs232Targets = {
heltec_t096_repeater_bridge_rs232: "heltec_t096_repeater",
heltec_t096_repeater_bridge_rs232_lora_ota_no_external_sensors:
"heltec_t096_repeater_lora_ota_no_external_sensors",
rak_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors:
"rak_4631_repeater_lora_ota_no_external_sensors",
rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors:
"rak_4631_repeater_lora_ota_no_external_sensors",
rak_4631_repeater_bridge_rs232_serial1: "rak_4631_repeater",
rak_4631_repeater_bridge_rs232_serial2: "rak_4631_repeater",
promicro_repeater_bridge_rs232_serial1: "promicro_repeater",
heltec_t114_without_display_repeater_bridge_rs232:
"heltec_t114_without_display_repeater",
heltec_t114_repeater_bridge_rs232: "heltec_t114_repeater",
rak_3112_repeater_bridge_rs232: "rak_3112_repeater",
rak_11310_repeater_bridge_rs232: "rak_11310_repeater",
waveshare_rp2040_lora_repeater_bridge_rs232:
"waveshare_rp2040_lora_repeater",
solarxiao_30s_repeater_bridge_rs232: "solarxiao_30s_repeater",
solarxiao_33s_repeater_bridge_rs232: "solarxiao_33s_repeater",
heltec_v3_repeater_bridge_rs232: "heltec_v3_repeater",
heltec_wsl3_repeater_bridge_rs232: "heltec_wsl3_repeater",
lilygo_tlora_v2_1_1_6_repeater_bridge_rs232:
"lilygo_tlora_v2_1_1_6_repeater",
};
return (profiles || []).filter(function (profile) {
const key = profile.hardware + "\n" + profile.variant;
const replacedAttachedTransport = profile.role === "companion" &&
(profile.mode === "usb" || profile.mode === "ble" ||
profile.mode === "wifi");
return !(replacedAttachedTransport && fullKeys.has(key));
profile.mode === "wifi" || profile.mode === "serial" ||
profile.mode === "ethernet" || profile.mode === "usb-ble");
const replacedByUsbBle = profile.role === "companion" &&
(profile.mode === "usb" || profile.mode === "ble") &&
combinedUsbBleKeys.has(key);
const replacedTerminal = profile.role === "terminal" &&
terminalReplacementKeys.has(key);
const rs232Replacement = mergedRs232Targets[
String(profile.target || "").toLowerCase()
];
const replacedRs232 = Boolean(rs232Replacement &&
targets.has(rs232Replacement));
return !(replacedAttachedTransport && fullKeys.has(key)) &&
!replacedByUsbBle && !replacedTerminal && !replacedRs232;
});
}
@@ -440,6 +499,24 @@
});
}
function applyMergedStandardUsbLoggingCapabilities(profiles) {
return (profiles || []).map(function (profile) {
const safeRole = [
"companion", "repeater", "room", "sensor", "terminal",
].includes(profile.role);
const unsafeCompanionMode = profile.role === "companion" &&
(profile.mode === "ble" || profile.mode === "full");
if (profile.logging !== "none" || profile.ota !== "none" ||
!safeRole || unsafeCompanionMode) {
return profile;
}
profile.logging = "usb-runtime";
profile.loggingModes = ["none", "usb"];
return profile;
});
}
function hardwareFamilyFor(hardware, hardwareNames) {
const value = String(hardware || "");
const lowerValue = value.toLowerCase();
@@ -539,8 +616,10 @@
}).filter(function (profile) {
return !isHiddenLegacyProfile(profile);
});
const profiles = omitTransportsReplacedByFull(
applyFullCompanionCapabilities(visibleProfiles)
const profiles = applyMergedStandardUsbLoggingCapabilities(
omitTransportsReplacedByFull(
applyFullCompanionCapabilities(visibleProfiles)
)
).sort(function (a, b) {
return a.target.localeCompare(b.target, undefined, {
numeric: true,
@@ -738,7 +817,11 @@
"With no saved SSID, the setup AP stays available for 30 minutes after each boot, then Wi-Fi powers off automatically until reboot or an explicit start webconfig command. A configured Wi-Fi mode keeps reconnecting instead."
);
} else if (profile.logging === "usb-runtime") {
if (profile.dedicatedUsbLogging) {
if (!isFullCompanion(profile)) {
extra.push(
"This ordinary image includes USB logging. Use get usb.logging and set usb.logging off|on to select and save normal or logging operation."
);
} else if (profile.dedicatedUsbLogging) {
extra.push(
"Full Companion starts with USB logging off and only interface 00. Use get usb.logging, or set usb.logging on reboot to add interface 02; set usb.logging off reboot removes it again."
);
@@ -1118,6 +1201,8 @@
parseFirmwareAsset: parseFirmwareAsset,
parseTargetProfile: parseTargetProfile,
applyFullCompanionCapabilities: applyFullCompanionCapabilities,
applyMergedStandardUsbLoggingCapabilities:
applyMergedStandardUsbLoggingCapabilities,
applyDualCdcFullCompanionCapabilities: applyFullCompanionCapabilities,
applyNrf52FullCompanionCapabilities:
applyFullCompanionCapabilities,
+21 -7
View File
@@ -49,7 +49,7 @@ an old or failed artifact without a verified sidecar.
| Repeater | Full repeater administration surface, subject to the profile differences below |
| Room server | Room-server administration surface, subject to the profile differences below |
| Sensor | Sensor command surface; it does not acquire the repeater administration tree |
| Serial, USB, BLE, or WiFi companion | Uses the companion protocol; any serial diagnostics are target-specific |
| Full, serial, Ethernet, USB, BLE, or WiFi companion | Uses the companion protocol; Full combines every qualified transport for that exact board |
| KISS modem | Uses the KISS/TNC frame interface, not the repeater text CLI |
| Bridge | Uses its base role plus commands for the bridge transport compiled into that target |
@@ -65,8 +65,8 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM.
| Build/profile | Command availability |
|---|---|
| Standard non-MQTT repeater or room server | Keeps the normal role CLI. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. |
| Standard logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile and adds compiled logging behavior. CommonCLI roles persist `get/set usb.logging`. Roles covered by a Full image with runtime logging are not duplicated here. |
| Standard non-MQTT repeater or room server | Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent `get/set usb.logging`. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. |
| Legacy standard logging | No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose `MESH_DEBUG`. |
| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and a 254-entry neighbor table. |
| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured 4,808-byte flash cost. Both artifacts carry the same stable OTA target identity and are checked for `ota ...` and `retry.preset`; RAK artifacts also verify the retained monitor drivers. |
| ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. |
@@ -74,10 +74,11 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM.
| FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. |
| Dual-CDC Full Companion | nRF52 and qualified native-USB ESP32-S3 Full images use one physical USB connection. Fresh installs expose only interface `00` for framed Companion/terminal/mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. They also provide BLE and source-only LoRa OTA; ESP32 additionally provides WiFi. `get/set usb.logging` persistently controls whether the logging interface is present. |
| Single-TTY Full Companion | ESP32 Full images without dual CDC start with framed Companion on their one TTY. `set usb.logging on` switches it to an input-capable plaintext logging terminal; `set usb.logging off` replies and then restores framed Companion automatically. BLE, WiFi, and source-only LoRa OTA remain available. |
| `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery or routing commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles also retain their GPS commands and provider. The RAK4631 Serial1 RS232 bridge remains GPS-off because both features require Serial1. The legacy target suffix is retained for OTA identity compatibility. |
| `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. |
`logging`, `OTA`, and `FULL` describe independent build features. Do not infer
that a command is missing merely because `logging` appears in the filename.
`logging`, `OTA`, and `FULL` describe independent build features in historical
filenames. Current standard artifacts use no `-logging-` infix because their
USB logging is runtime controlled.
## Canonical bulk-build policy
@@ -94,7 +95,8 @@ available from a canonical image:
persisted `radio.rxgain on|off` setting; the G3 alias changed only the
advertised default name.
- When a board has a Full Companion, that one artifact replaces its
separate USB, BLE, ordinary WiFi, and USB packet-logging Companion artifacts.
separate USB, BLE, ordinary WiFi, hardware-serial, Ethernet Companion, and
USB packet-logging Companion artifacts.
It provides BLE and source-only LoRa OTA; ESP32 also provides ordinary WiFi.
Dual-CDC builds keep framed traffic on interface `00` and add logging on
interface `02` after a reboot. Single-TTY builds switch interface `00` into
@@ -108,6 +110,18 @@ available from a canonical image:
available as explicit build targets, but are not canonical release artifacts
when the qualified Full image fits.
- Standalone Terminal Chat is omitted when the same exact hardware has either
Full Companion or a USB Companion, because their local text terminal supplies
the same role. Heltec E290 and T190 similarly publish one combined USB + BLE
Companion. RAK4631 repeater and room-server Ethernet builds stay separate;
only the RAK4631 Ethernet Companion is folded into Full Companion.
- Matching RS-232 bridge roles are compiled into the normal repeater and
selected at runtime with `bridge.enabled`, `bridge.baud`, and `bridge.uart`.
Historical bridge names remain directly buildable but are omitted from bulk
releases. Wio-E5 remains separate because its normal image has only 916
bytes free, while the combined image exceeds the fixed 240 KiB application
partition by 2,192 bytes.
The old aliases still work with `build-firmware` and
`build-matching-firmwares`. Dedicated repeater LoRa OTA receiver images are not
collapsed; they retain their exact storage, bootloader, role, and target
+8 -6
View File
@@ -11,10 +11,10 @@ own interface, so those build roles are not represented here.
Build columns mean:
- **Standard** - the ordinary non-MQTT artifact, without the `logging` or
explicit `ota` filename marker.
- **Logging** - the ordinary non-MQTT `-logging-` artifact. Logging does not
remove commands by itself.
- **Standard** - the ordinary non-MQTT artifact, without an explicit `ota`
filename marker. Safe plaintext-USB roles embed runtime USB logging.
- **Logging** - the legacy `-logging-` profile, now represented by the same
ordinary artifact and retained as a comparison column only.
- **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. Its
optional external-sensor drivers are removed, but onboard GPS is retained.
- **FULL unified** - the expanded-partition ESP32 artifact with LoRa OTA, the
@@ -72,7 +72,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; Full Companion uses either a reboot-controlled second CDC or an input-capable single-TTY logging terminal | No | Yes | No |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; Full Companion uses either a reboot-controlled second CDC or an input-capable single-TTY logging terminal | Yes | Yes | No |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes |
@@ -155,6 +155,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW is ESP32 only | No | No | No |
| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW is ESP32 only | No | No | No |
| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 bootloader metadata | Yes | Yes | Yes |
@@ -235,7 +236,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; Full Companion uses either a reboot-controlled second CDC or an input-capable single-TTY logging terminal | No | Yes | No | No | Yes |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; Full Companion uses either a reboot-controlled second CDC or an input-capable single-TTY logging terminal | Yes | Yes | No | No | Yes |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes |
@@ -318,6 +319,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature |
| Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature |
| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature |
| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW bridge | No | No | No | Feature | Feature |
| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 only | No | No | No | No | No |
+41 -6
View File
@@ -562,6 +562,23 @@ set flag bit 0.
---
## Set Companion display rotation
SSD1306 Full Companion builds support a persisted runtime orientation:
```text
get display.rotation
set display.rotation 0
set display.rotation 90
set display.rotation 180
set display.rotation 270
```
The values are clockwise degrees. `0` clears the override and restores the
board's compiled default. Unsupported display drivers return an error.
---
## Logging
Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every
@@ -576,8 +593,9 @@ timing, hash, type, route, and payload information. Frames that cannot be
decoded still emit their `RAW:` line. Transmitted packets emit the decoded TX
summary.
Ordinary `-logging-` artifacts keep packet logging separate from LoRa OTA.
Use the separately named `-ota-` artifact when LoRa OTA is required. A
Ordinary non-OTA artifacts compile packet logging into the canonical image and
control its live USB output at runtime; no separate `-logging-` artifact is
emitted. Use the separately named `-ota-` artifact when LoRa OTA is required. A
`-full-usb-wifi-ota-` artifact combines USB packet logging, direct WiFi MQTT,
LoRa OTA, and the expanded FULL feature set. A `-full-logging-ota-` artifact is
emitted only when that hardware/role has no matching WiFi MQTT environment.
@@ -594,10 +612,11 @@ set usb.logging on reboot
set usb.logging off reboot
```
These commands are compiled into logging artifacts and every Full Companion.
They control live USB debug and packet output. CommonCLI roles save the setting
in `/com_prefs`, so it survives reboot; their first boot defaults to on. Full
Companion starts off on a fresh installation.
These commands are compiled into ordinary USB-loggable artifacts and every
Full Companion. They control live USB debug and packet output. CommonCLI roles
save the setting in `/com_prefs`, so it survives reboot; their first boot
defaults to on. Full Companion and ordinary USB Companion start off on a fresh
installation so diagnostics cannot corrupt framed traffic.
On Full Companion these lines belong to its text terminal, not `meshcli`'s
Binary `get/set` parameter namespace. Open interface `00`, send
@@ -3446,6 +3465,22 @@ Requires WiFi connected and the MQTT bridge running.
---
#### View or change the UART used by the bridge (RS-232 only)
**Usage:**
- `get bridge.uart`
- `set bridge.uart <port>`
**Parameters:**
- `port`: Hardware UART number compiled for the board. Most boards expose one
fixed UART. RAK4631 accepts `1` or `2`; UART 2 is the default so UART 1 can
remain available to GPS.
The setting is persistent and restarts an enabled bridge immediately. Normal
repeater artifacts start with `bridge.enabled off`; configure the UART and baud
rate before running `set bridge.enabled on`.
---
#### View or change the channel used for bridging (ESPNow only)
**Usage:**
- `get bridge.channel`
+96 -5
View File
@@ -1,7 +1,7 @@
# Companion Protocol
- **Last Updated**: 2026-08-18
- **Protocol Version**: 13 (`FIRMWARE_VER_CODE`)
- **Last Updated**: 2026-08-26
- **Protocol Version**: 14 (`FIRMWARE_VER_CODE`)
> The command and response catalogs track
> `examples/companion_radio/MyMesh.cpp`. Applications should negotiate the
@@ -60,6 +60,22 @@ MeshCore Companion devices expose a BLE service with the following UUIDs:
- **RX Characteristic** (App -> Firmware): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
- **TX Characteristic** (Firmware -> App): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
An nRF52 Full Companion also exposes a separate LoRa mOTA source service. It
does not replace or multiplex the normal Companion UART service:
- **mOTA Service**: `14518FC2-7E7A-4D84-8CAE-6664B0234CF2`
- **Device Request** (notify): `2BFAA1EE-7030-459A-B65A-E7CFD5B09735`
- **Host Response** (write with response): `ACF38A51-DD58-4DCE-917F-0B1135E41B1A`
All three mOTA attributes require an encrypted, MITM-authenticated connection
using the Companion's six-digit PIN. The source remains inactive until the
client subscribes to Device Request and explicitly starts it with command
`0x4B`. See [Bluetooth LoRa mOTA source](#bluetooth-lora-mota-source).
ESP32 and nRF52 Companion UART characteristics require the same PIN-protected,
MITM-authenticated link. ESP32 advertises DisplayOnly capability so a central
must enter the PIN shown by the Companion; a Just Works bond is insufficient.
### Connection Steps
1. **Scan for Devices**
@@ -170,7 +186,7 @@ The first byte indicates the packet type (see [Response Parsing](#response-parsi
## Commands
The first byte selects the command. This is the current protocol-v13 command
The first byte selects the command. This is the current protocol-v14 command
catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
| Byte | Firmware name | Purpose |
@@ -222,12 +238,14 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
| `0x44` / `0x45` | `CMD_GET_RADIO_RXGAIN` / `CMD_SET_RADIO_RXGAIN` | Read or set the radio chip's boosted receive-gain mode. |
| `0x46` / `0x47` | `CMD_GET_WIFI_POWER_SAVE` / `CMD_SET_WIFI_POWER_SAVE` | Read or set ESP32 Companion WiFi modem sleep. |
| `0x48` / `0x49` | `CMD_GET_BLUETOOTH_NAME` / `CMD_SET_BLUETOOTH_NAME` | Read or set the independent Bluetooth device name. |
| `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command on a Full Companion. |
| `0x4B` | `CMD_BLE_MOTA_SOURCE` | Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. |
The sections below detail the most common frames. Refer to the source named
above for command bodies that are not expanded here.
The additive hardware-setting commands `0x42`-`0x47` do not change any existing
version-13 frame layout. Clients should probe the command they need and treat
legacy frame layout. Clients should probe the command they need and treat
`ERR_CODE_UNSUPPORTED_CMD` as feature absence.
Both gain-command pairs can be used over the normal binary Companion
@@ -273,6 +291,79 @@ override and restores `MeshCore-<advert name>`. A successful SET replies with
control characters, malformed UTF-8, or oversized values return
`ERR_CODE_ILLEGAL_ARG`; a storage failure returns `ERR_CODE_BAD_STATE`.
### Bluetooth LoRa mOTA source
Protocol v14 lets a phone use an nRF52 Full Companion as the source for a
remote repeater update without a USB computer. The normal Companion service
still carries contacts, repeater login, CLI messages, and these two control
commands. The separate mOTA service carries only host-folder request/response
frames.
`CMD_EXEC_LOCAL_OTA_CONTROL` (`0x4A`) is followed by 1-174 printable ASCII
bytes. Full Companion accepts only these local command families:
```text
tempradio <freq_kHz>,<bw_kHz>,<sf>,<cr>,<minutes>
normalradio
ota ...
```
`ota folder ...` is deliberately rejected because USB and Bluetooth source
ownership must not be changed through the wrong transport. Embedded NUL, CR,
LF, other control bytes, non-ASCII bytes, empty commands, and oversized frames
return `ERR_CODE_ILLEGAL_ARG`. A recognized command replies with
`RESP_CODE_OK`, one unsigned reply-length byte, and exactly that many printable
result bytes. Shell metacharacters are rejected as well; the text is dispatched
only to the in-firmware parser and is never passed to a host shell. Firmware
without the Full Companion feature returns `ERR_CODE_UNSUPPORTED_CMD`.
`CMD_BLE_MOTA_SOURCE` (`0x4B`) has one action byte:
| Action | Meaning |
| ---: | --- |
| `0` | Read status without changing it. |
| `1` | Attach and enumerate the subscribed Bluetooth host's `.mota` catalog. |
| `2` | Detach the Bluetooth source. |
Current firmware returns eleven bytes (legacy protocol-v14 previews returned
the seven-byte prefix only):
```text
00 action flags offered_le16 advertised_le16 source_packets_sent_le32
```
Flag bit `0x01` means the encrypted GATT channel is connected and Device
Request notifications are enabled. Bit `0x02` means the Bluetooth catalog is
attached. Bit `0x04` means USB or another folder transport currently owns the
source slot. Start without a ready subscription, or while another source link
owns the slot, returns `ERR_CODE_BAD_STATE`. A non-nRF52 Full Companion returns
`ERR_CODE_UNSUPPORTED_CMD`. `source_packets_sent` is a per-attachment count of
OTA packets accepted by the Companion's LoRa transmit adapter, including
catalog/manifest traffic, data, proofs, and retries. It wraps as an unsigned
32-bit value. Clients should accept the legacy seven-byte response and display
the packet counter as unavailable.
After a successful start, the device sends the same bounded seeder frames used
by `motatool serve` on Device Request:
```text
device -> host: 'M' 'S' op args... xor(op || args)
host -> device: 'm' 's' op status payload... xor(all prior bytes)
```
Device requests are at most 11 bytes. A source response is at most 197 bytes.
The host may split one response across multiple write-with-response operations
when the negotiated ATT payload is smaller; it must preserve byte order and
must not interleave another response. Bad checksums, partial frames, overflow,
unsubscribe, loss of encryption, or disconnect fail closed. The firmware then
detaches the catalog and stops advertising its entries. USB and Bluetooth
folder sources are mutually exclusive.
A Linux reference controller and seeder is provided at
`tools/ble_mota/ble_mota_seeder.py`. It verifies every input with `motatool`
before offering it. A mobile implementation should apply the same complete
container verification before serving files.
### 1. App Start
**Purpose**: Initialize communication with the device. Must be sent first after connection.
@@ -305,7 +396,7 @@ Byte 1: Highest companion protocol version understood by the app
**Example** (hex):
```
16 0D
16 0E
```
**Response**: `PACKET_DEVICE_INFO` (0x0D) with device information
+77 -3
View File
@@ -12,8 +12,10 @@ firmware as an mOTA image.
| BLE Binary Companion | Yes | Yes |
| USB ASCII terminal | Yes | Yes |
| Dedicated USB plaintext logging | Qualified native-USB ESP32-S3 profiles | Yes |
| Host-backed LoRa mOTA source | WiFi TCP 5001 | Exclusive USB mode |
| Host-backed LoRa mOTA source | WiFi TCP 5001 | Exclusive USB mode or encrypted BLE |
| WiFi Companion/WebConfig | Yes | No - nRF52840 has no WiFi |
| Hardware serial Companion | On targets with assigned serial pins | On targets with assigned serial pins |
| Ethernet Companion | On targets with an Ethernet module | On RAK4631 with RAK13800 |
| LoRa self-update | No | No |
## Build and install
@@ -63,8 +65,11 @@ Canonical Companion bulk builds also omit legacy `_ps` and `_femoff` aliases.
Power saving and controllable FEM receive gain are persisted runtime settings;
the old names remain available through an explicit `build-firmware` command
for compatibility. Full Companion replaces separate USB, BLE, ordinary WiFi,
and USB-only packet-logging release artifacts whenever the exact board supports
the combined profile. Dual-CDC builds separate framed traffic and logs;
hardware-serial, Ethernet Companion, Terminal Chat, and USB-only packet-logging
release artifacts whenever the exact board supports those combined transports.
Direct builds of the legacy targets remain available. RAK4631 repeater and room
server Ethernet builds remain separate because they are different standalone
roles, not Companion transports. Dual-CDC builds separate framed traffic and logs;
single-TTY builds make those modes mutually exclusive. In WebConfig, use the
**FEM RX boost** switch. From the text terminal (USB, or TCP 5002 on ESP32), use:
@@ -85,6 +90,22 @@ commands control the external receive and transmit paths. The selected states
are applied immediately and retained after reboot. FEM TX gain is reported as
unsupported on boards without software-selectable PA gain.
SSD1306 display builds also persist a runtime orientation. This replaces the
separate rotated Full Companion release image:
```text
get display.rotation
set display.rotation 90
set display.rotation 180
set display.rotation 270
set display.rotation 0
```
`0` resets the screen to that board's compiled default orientation.
Heltec E290 and T190 use one `usb_ble` Companion artifact for simultaneous USB
and BLE rather than publishing separate USB-only and BLE-only images.
Device power saving is separate from LoRa RXPS. It can be changed in WebConfig
with the **Device power saving** switch or from the text terminal:
@@ -219,6 +240,7 @@ itself.
| ESP32 | TCP 5001 | Host `.mota` folder from `motatool serve --tcp` |
| ESP32 | TCP 5002 | Full Companion text terminal; same role commands as the USB terminal |
| nRF52 | USB mOTA mode | Host `.mota` folder from `motatool serve --serial` |
| nRF52 | Encrypted BLE mOTA service | Paired phone/tablet/Linux host `.mota` catalog |
Delivery-required replies are returned only to the interface which supplied the
latest command. A contact-list stream keeps that route locked from
@@ -356,6 +378,10 @@ only one may own the terminal at a time. Entering USB terminal mode closes an
active TCP terminal session. Disconnecting TCP clears pending terminal-only
state without cancelling Binary Companion delivery or radio retries.
Both terminal transports accept `reboot`. The reply is sent first and the
device reboots one second later, so a script can distinguish an accepted reboot
from an abruptly lost connection.
Port 5002 is plaintext and has no device-local login gate. A remote-admin
password entered with `login` is sent across the LAN connection as typed even
though the terminal does not echo it. Use port 5002 only on a trusted LAN or a
@@ -459,6 +485,50 @@ While mOTA mode owns USB:
No manual mode token or modified `motatool` build is required.
## nRF52 Bluetooth mOTA source
Protocol v14 also lets a phone, tablet, or Bluetooth-capable Linux host feed
the `.mota` catalog to an nRF52 Full Companion. Normal Companion commands stay
on the Nordic UART service. Firmware data uses a separate GATT service, so
binary app traffic cannot be mistaken for a firmware block.
The client must pair with the Companion PIN, subscribe to the mOTA Device
Request characteristic, and send `CMD_BLE_MOTA_SOURCE` action `start` over the
normal Binary Companion connection. The source is available only while that
encrypted MITM-authenticated connection remains active. Disconnecting,
unsubscribing, overflowing a frame, or receiving malformed data automatically
detaches the catalog. USB and BLE source modes are mutually exclusive.
The included Raspberry Pi reference client validates each `.mota` with
`motatool`, schedules the local TempRadio window, serves until interrupted,
then detaches and restores the normal radio tuple:
```bash
python3 tools/ble_mota/ble_mota_seeder.py \
--device MeshCore-MyCompanion \
--dir ./motas \
--local 'tempradio 909.950,250,5,5,120'
```
Prefer this relative `tempradio` form when the phone/Pi and radio clocks may
disagree. It starts a duration on the Companion and does not compare their
wall clocks. Use the absolute `tempradioat` scheduler only after synchronizing
the participating nodes.
Use `--pair` when the Linux host has not already bonded. BlueZ must have an
agent capable of entering or confirming the six-digit PIN. Use `--source
status` without `--dir` for a read-only channel/status check. The complete
UUID, frame, action, and status definitions are in the
[Companion protocol](./companion_protocol.md#bluetooth-lora-mota-source).
This reference process stands in for the phone application. A mobile app can
use the same sequence while retaining its normal contact and Repeater Admin
UI: log in to the destination, put each required node on the same bounded
TempRadio tuple, start the local Bluetooth catalog, then send the normal remote
`ota ls`, `ota pull`, and `ota install` commands. The destination still checks
container geometry, hardware identity, hashes, signature policy, and the
OTAFIX bootloader before installation.
## Serve mOTA images manually
First put the destination, required relays, controller, and source on the same
@@ -514,6 +584,10 @@ 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.
As a cable-free alternative, keep the normal Companion BLE session open and
run the Bluetooth reference client shown in the nRF52 Bluetooth section. Do
not run the USB seeder at the same time.
Both platforms intentionally refuse firmware installation commands such as:
```text
+23 -5
View File
@@ -140,8 +140,12 @@ from the published firmware assets.
| LoRa OTA source only | Full Companion serving a host-supplied update to another node without self-installing it |
Connection and bridge choices depend on the selected role. Companion firmware
may offer Full, Bluetooth, USB, Wi-Fi, serial, or Ethernet transports.
Repeaters may offer standard, ESP-NOW bridge, RS-232 bridge, Ethernet, or MQTT
may offer Full, combined USB + Bluetooth, Bluetooth, USB, Wi-Fi, serial, or
Ethernet transports.
Normal repeater firmware includes runtime-controlled RS-232 support where the
board has room; use `set bridge.enabled on` after configuring `bridge.uart` and
`bridge.baud`. The Wio-E5 remains the capacity exception and offers a separate
RS-232 image. Repeaters may also offer separate ESP-NOW, Ethernet, or MQTT
observer modes.
## FULL versus standard
@@ -169,9 +173,23 @@ available as an override. A saved SSID switches to the normal indefinite
reconnect behavior instead.
Full Companion profiles use one binary for USB, BLE, ordinary Wi-Fi on ESP32,
source-only LoRa OTA, and optional USB packet logging. The picker therefore
omits separate attached-transport and USB-logging choices whenever the exact
Full recipe exists. Fresh installs default to logging off.
source-only LoRa OTA, Terminal Chat, optional USB packet logging, and any
board-qualified serial or Ethernet Companion transport. Bulk builds therefore
omit separate attached-transport, Terminal Chat, and USB-logging artifacts
whenever the exact Full recipe exists. RAK4631 repeater and room-server
Ethernet images remain separate roles. Fresh installs default to logging off.
When Full Companion does not fit but a matching USB Companion does, that USB
artifact also supplies Terminal Chat and replaces its standalone release image.
Heltec E290 and T190 publish a combined USB + BLE Companion. SSD1306 Full
Companion builds use `set display.rotation 90|180|270`; `0` restores the board
default, so a separate rotated release image is not recommended.
Ordinary non-OTA roles also use one artifact for normal operation and USB
logging. Select the saved mode with `set usb.logging off|on`; no `-logging-`
artifact is emitted. KISS, BLE-only Companion, and constrained LoRa-OTA
receiver images retain their protocol/partition contracts and do not inherit
plaintext USB logging.
Dual-CDC nRF52 and qualified native-USB ESP32-S3 builds keep Binary Companion
on interface `00`; `set usb.logging on reboot` adds plaintext interface `02`.
+4 -4
View File
@@ -21,10 +21,10 @@ example and is disabled unless the endpoint is started with `--allow-reboot`.
Text such as `reboot now`, `cpu-temp; reboot`, and embedded newlines is not a
command: the endpoint accepts only an exact allowlist match.
The bridge is included in normal repeater firmware. The specialized Wio-E5
RS232 bridge image omits it because the RS232 application already fills its
fixed 240 KiB application partition; use the normal Wio-E5 repeater image when
the USB/MQTT host service is needed.
The bridge is included in normal repeater firmware except on Wio-E5. Its
specialized RS232 bridge image omits the host service because the combined
application exceeds the fixed 240 KiB partition; use the normal Wio-E5
repeater image when the USB/MQTT host service is needed.
## Run alongside meshcoretomqtt
+17 -2
View File
@@ -90,8 +90,9 @@ environmental sensors while retaining board-native features such as displays, bu
and GPS where the target uses the GPS-preserving lean profile. RAK3401 and RAK4631 reduced builds also
retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers add 4,808
bytes over the otherwise reduced image. The RAK3401 OTA repeater retains RAK12501 support in sensor slot A;
slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater retains GPS except for
its Serial1 RS232 bridge, which uses the same UART. Selected nRF52 boards with matched external
slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater retains GPS and defaults
its runtime RS-232 bridge to Serial2; Serial1 and GPS use the same UART and must not be enabled together.
Selected nRF52 boards with matched external
QSPI application and bootloader support can instead make the normal full-sensor
repeater install-capable; those targets do not need to reserve internal flash
for the downloaded container. SolarXiao 30S and 33S use this matched external-QSPI
@@ -156,6 +157,10 @@ An nRF52 `companion_radio_full` starts in USB Binary mode. Use
`+++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.
Protocol v14 can instead take the catalog from a paired phone or Linux host
over a separate encrypted BLE mOTA service while Binary Companion remains
active. USB and BLE catalog sources are mutually exclusive. See the
[Full Companion Bluetooth source guide](./companion_radio_full.md#nrf52-bluetooth-mota-source).
For an ESP32 WiFi companion or FULL ESP32 source with active WiFi, use its dedicated OTA seeder:
@@ -356,6 +361,16 @@ Replace `/dev/ttyACM0` with the USB serial device of the source companion select
`motatool` attaches the folder to the source, which advertises the update over LoRa while its temporary-radio
window is active. KISS modem serial ports cannot be used here.
For an nRF52 Full Companion and a Bluetooth-capable host, the equivalent
cable-free source is:
```bash
python3 tools/ble_mota/ble_mota_seeder.py \
--device MeshCore-MyCompanion \
--dir ./motas \
--local 'tempradio 909.950,250,5,5,120'
```
Leave this command running until the destination finishes downloading.
### 3. Find and download the update
+22 -10
View File
@@ -133,8 +133,8 @@ matched external QSPI staging, so their ordinary full-sensor repeater is already
redundant lean sibling is generated. Integrated GPS and other
board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The RAK3401
OTA repeater also retains RAK12501 GPS support; install that module in sensor slot A because slot D conflicts
with the RAK13302 radio's BUSY/DIO1 lines. RAK4631 OTA repeaters retain GPS except for the Serial1 RS232
bridge, whose bridge and GPS would contend for the same UART.
with the RAK13302 radio's BUSY/DIO1 lines. RAK4631 OTA repeaters retain GPS and default the runtime RS-232
bridge to UART 2; do not enable GPS and an explicitly selected UART 1 bridge at the same time.
ESP32 siblings retain the compact browser WiFi updater and use the full
254-entry neighbor table. RP2040 and STM32 targets are not offered because
those platforms do not yet have a safe bootloader/apply path.
@@ -161,8 +161,9 @@ Option 3 in `build.sh` emits one `*-full-usb-wifi-ota-*` ESP32 artifact for each
FULL-capable non-companion hardware/role that has a matching MQTT environment.
It compiles USB packet logging and direct WiFi MQTT together. A
`*-full-logging-ota-*` fallback is emitted only when there is no MQTT sibling;
the old separate standard-logging and non-MQTT FULL twins are skipped for
covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with
ordinary non-OTA roles compile runtime USB logging into their canonical image,
so separate standard-logging artifacts are not emitted. Non-MQTT FULL twins
are also skipped for covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with
expanded FULL partitions. Menu option 8, or `build-full-esp32-firmwares`,
builds the unified profiles plus necessary fallbacks. Menu option 9, or
`build-full-esp32-logging-firmwares`, builds only those fallbacks.
@@ -686,13 +687,15 @@ verify everything). The serve side (`OtaManager`) keeps a lightweight registry o
resident "views": `view0` (its own firmware) and one on-demand view loaded from a source when a request
targets an external mota. Every fetch message carries `manifest_id`, so dispatch is a registry lookup.
The same host-folder link is also a **pull destination** (the reverse direction): `ota pull <mid8> folder`
The USB/TCP host-folder link can also be a **pull destination** (the reverse direction): `ota pull <mid8> folder`
fetches a `.mota` off the mesh and streams it onto the host as `<mid>.mota` via the seeder STORAGE ops
(`OP_STAT/BEGIN/WRITE/SREAD/FIN`, see `MotaSeederProto.h`), using a `FolderMotaStore` as the fetch's
`OtaStore` instead of RAM/flash. This captures an exact copy of a device's firmware - e.g. to build a delta
against firmware you don't have. Resume is bookkeeping-free: `BEGIN` 0xFF-fills the file and, on reconnect
after a link drop (the fetch PAUSES, holding progress on the host - no RAM/flash fallback), `STAT`+`SREAD`
let the fetcher recompute and refill only the missing blocks.
The phone-oriented BLE link is deliberately source-only and does not register
a folder destination.
### 10.1 The `MotaSource` abstraction (`OtaSource.h`)
@@ -717,7 +720,8 @@ blocks) and streams payload blocks from the source on demand; proofs are generat
### 10.2 The `mota-seeder` transport (`MotaSeederProto.h`)
A `MotaSource` is fed by a host that serves a folder over the device's **USB serial** (the same console the
CLI uses - no extra hardware) or, on an ESP32 WiFi companion or FULL ESP32 role, over **WiFi (TCP)**. The
CLI uses - no extra hardware), on an ESP32 WiFi companion or FULL ESP32 role over **WiFi (TCP)**, or on an
nRF52 Full Companion over an encrypted **BLE GATT** service. The
host is the
standalone Rust tool [`motatool`](https://github.com/vk496/motatool) (`motatool serve --serial <port>` /
`--tcp <host[:port]>`, which also builds + verifies + inspects `.mota`). The device only emits request frames *while
@@ -761,6 +765,10 @@ deployment runner temporarily uses `0.3` on managed relays.
work**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and `mota-seeder`
request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative
source connection: use its dedicated seeder port with `motatool serve --tcp <host>:5001`.
An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe
to its mOTA request characteristic, and use protocol-v14
`CMD_BLE_MOTA_SOURCE`. That BLE path is source-only; it does not expose the
reverse `FolderMotaStore` capture operations.
Device CLI: `ota folder on` (attach + announce), `ota folder` (list), `ota folder off`. Build flag
`OTA_FOLDER_SERIAL` (default stream = console `Serial`; override `OTA_FOLDER_SERIAL_STREAM` + define
@@ -784,10 +792,14 @@ disappearing. Operators should split a large chain or use a higher-capacity/SD s
resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a
reliable stream and the **WiFi (TCP)** transport reuses it as-is - both ends just treat the socket as a
byte stream (on-device, `SerialMotaSource` runs verbatim over an Arduino `Stream`-compatible `WiFiClient`;
`motatool`'s `TcpTransport` mirrors its `SerialTransport`). A future framed link such as **BLE GATT** (an
Android phone relaying a folder) could carry the same ops with no magic/checksum at all - a request
characteristic write delivers `op + args`, the reply notifies `status + payload`. `motatool` reflects this
split: a transport-free `SeederCore` (the catalog logic) under a swappable framing/transport layer.
`motatool`'s `TcpTransport` mirrors its `SerialTransport`). The nRF52 Full
Companion's **BLE GATT** path also reuses the exact frame and checksum. Device
requests are notifications on a dedicated characteristic and host responses
are ordered write-with-response fragments on a second characteristic. Keeping
the same framing makes retries and corruption handling identical across USB,
TCP, and GATT. The Linux reference implementation is
`tools/ble_mota/ble_mota_seeder.py`; a phone app can implement the same
transport-free catalog operations.
---
+8 -5
View File
@@ -55,9 +55,9 @@ the GPS in sensor slot A. Slot D's reset/PPS lines conflict with the RAK13302
radio's BUSY/DIO1 lines.
The RAK4631 internal-flash OTA repeater likewise retains GPS. A RAK12501 can
use sensor slot A or D; a RAK12500 can use slot A or C. The RAK4631 Serial1
RS232 OTA bridge is the exception because the bridge owns the GPS UART. The
Serial2 RS232 OTA bridge retains GPS on Serial1.
use sensor slot A or D; a RAK12500 can use slot A or C. Its runtime RS-232
bridge defaults to Serial2 so GPS can retain Serial1. If you explicitly select
Serial1 with `set bridge.uart 1`, turn GPS off before enabling the bridge.
Selected nRF52 repeaters with dedicated external QSPI can now stage the
complete package off-chip, so their normal full-sensor repeater build can
@@ -394,14 +394,17 @@ remote area.
1. Put the firmware files (`.mota` files - see below) in a folder on the computer.
2. Install the helper tool once - the standalone `motatool` CLI (<https://github.com/vk496/motatool>) -
then point it at your node and the folder - over the node's **USB serial**, or over **WiFi** if it is
an ESP32 WiFi companion or FULL ESP32 node:
then point it at your node and the folder - over the node's **USB serial**, over **WiFi** if it is
an ESP32 WiFi companion or FULL ESP32 node, or over encrypted **Bluetooth** if it is an nRF52 Full Companion:
```
git clone https://github.com/vk496/motatool && cargo install --path ./motatool
# over USB serial:
motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v
# ...or over WiFi: the seeder is on dedicated TCP port 5001:
motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v
# ...or over paired BLE to an nRF52 Full Companion (protocol v14):
python3 tools/ble_mota/ble_mota_seeder.py \
--device MeshCore-MyCompanion --dir ./my_firmware/
```
It answers the node's requests; your node then advertises those updates to neighbours, who can
`ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect.
+10 -2
View File
@@ -107,13 +107,21 @@ GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep.
get usb.logging
set usb.logging {on|off} [reboot]
```
Shows or changes persistent live USB debug and packet output in a Companion
logging artifact or Full Companion. Full starts off on a fresh install.
Shows or changes persistent live USB debug and packet output in an ordinary
USB-loggable Companion or Full Companion. USB Companion and Full start off on
a fresh install with logging disabled to protect framed traffic.
Dual-CDC Full changes its interface count after a reboot and keeps Companion on
interface `00`. Single-TTY Full needs no reboot: logging uses the active text
terminal, which continues accepting `set usb.logging off` and automatically
restores Binary Companion after that reply.
```
reboot
```
Sends an acknowledgement, then reboots the Companion one second later. The
delay gives either the USB terminal or the Full Companion TCP terminal on port
5002 time to deliver the reply before its transport disappears.
```
get radio.rxps
get radio.rxps.config
@@ -71,5 +71,10 @@ public:
int channel_idx = -1,
const char* channel_name = nullptr) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual bool supportsDisplayRotation() const { return false; }
virtual bool setDisplayRotationDegrees(uint16_t degrees) {
(void)degrees;
return false;
}
virtual void loop() = 0;
};
@@ -46,6 +46,9 @@
#ifndef COMPANION_FEATURE_USB_MOTA_SOURCE
#define COMPANION_FEATURE_USB_MOTA_SOURCE 0
#endif
#ifndef COMPANION_FEATURE_BLE_MOTA_SOURCE
#define COMPANION_FEATURE_BLE_MOTA_SOURCE 0
#endif
#ifndef COMPANION_FEATURE_DEDICATED_USB_LOGGING
#define COMPANION_FEATURE_DEDICATED_USB_LOGGING 0
#endif
@@ -70,6 +73,12 @@
#error "COMPANION_FEATURE_USB_MOTA_SOURCE requires nRF52 USB folder seeding"
#endif
#if COMPANION_FEATURE_BLE_MOTA_SOURCE \
&& !(defined(COMPANION_RADIO_FULL) && defined(NRF52_PLATFORM) \
&& defined(BLE_PIN_CODE) && defined(ENABLE_OTA))
#error "COMPANION_FEATURE_BLE_MOTA_SOURCE requires an nRF52 Full Companion with BLE and OTA"
#endif
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS && !defined(ESP32_PLATFORM)
#error "COMPANION_FEATURE_MEMORY_DIAGNOSTICS requires ESP32"
#endif
+9
View File
@@ -355,6 +355,11 @@ void DataStore::loadPrefsInt(const char *filename, CompanionNodePrefs& _prefs, d
if (file.available() >= (int)sizeof(_prefs.bluetooth_name)) {
file.read((uint8_t *)_prefs.bluetooth_name,
sizeof(_prefs.bluetooth_name)); // 141
if (file.available()
>= (int)sizeof(_prefs.display_rotation_degrees)) {
file.read((uint8_t *)&_prefs.display_rotation_degrees,
sizeof(_prefs.display_rotation_degrees));
}
}
}
}
@@ -434,6 +439,10 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
== sizeof(_prefs.usb_logging_enabled); // 140
success = success && file.write((uint8_t *)_prefs.bluetooth_name,
sizeof(_prefs.bluetooth_name)) == sizeof(_prefs.bluetooth_name); // 141
success = success && file.write(
(uint8_t *)&_prefs.display_rotation_degrees,
sizeof(_prefs.display_rotation_degrees))
== sizeof(_prefs.display_rotation_degrees);
#if defined(NRF52_PLATFORM)
success = file.commit(success);
+167 -27
View File
@@ -1386,7 +1386,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors,
_prefs.airtime_factor),
#endif
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
_serial(NULL), _mota_source_control(NULL),
telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
_iter_started = false;
_cli_rescue = false;
#ifdef ENABLE_USB_INTERFACE
@@ -1412,9 +1413,7 @@ 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 MESH_USB_LOGGING_AVAILABLE
_usb_logging_reboot_at = 0;
#endif
_scheduled_reboot_at = 0;
#if COMPANION_FEATURE_TEMP_RADIO
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
@@ -1477,10 +1476,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.powersaving_policy_version = 0;
_prefs.wifi_enabled = 1;
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
#if defined(COMPANION_RADIO_FULL)
// Keep Full Companion's primary stream exclusively framed on a fresh
// install. Dual-CDC builds can add a diagnostics port; single-TTY builds
// switch the primary stream into the text terminal before emitting logs.
_prefs.display_rotation_degrees = 0;
#if defined(ENABLE_USB_INTERFACE)
// Keep a USB Companion's primary stream exclusively framed on a fresh
// install. Full dual-CDC builds can add a diagnostics port; single-TTY
// builds switch the primary stream into the text terminal before logs.
_prefs.usb_logging_enabled = 0;
#else
_prefs.usb_logging_enabled = 1;
@@ -1567,6 +1567,14 @@ void MyMesh::begin(bool has_display, bool radio_available) {
if (bluetooth_name_repaired) {
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
}
const bool display_rotation_repaired =
_prefs.display_rotation_degrees != 0
&& _prefs.display_rotation_degrees != 90
&& _prefs.display_rotation_degrees != 180
&& _prefs.display_rotation_degrees != 270;
if (display_rotation_repaired) {
_prefs.display_rotation_degrees = 0;
}
// sanitise bad pref values
_prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f);
@@ -1601,7 +1609,8 @@ void MyMesh::begin(bool has_display, bool radio_available) {
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
if (power_saving_default_migrated || bluetooth_name_repaired) {
if (power_saving_default_migrated || bluetooth_name_repaired
|| display_rotation_repaired) {
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
}
#if MESH_USB_LOGGING_AVAILABLE
@@ -1864,6 +1873,57 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
if (strcmp(command, "get display.rotation") == 0) {
if (_ui == NULL || !_ui->supportsDisplayRotation()) {
snprintf(reply, reply_size, "Error: display rotation is unsupported");
} else if (_prefs.display_rotation_degrees == 0) {
snprintf(reply, reply_size, "display.rotation default");
} else {
snprintf(reply, reply_size, "display.rotation %u",
(unsigned)_prefs.display_rotation_degrees);
}
return true;
}
if (strncmp(command, "set display.rotation", 20) == 0
&& (command[20] == 0 || command[20] == ' '
|| command[20] == '\t')) {
const char* value = command + 20;
while (*value == ' ' || *value == '\t') value++;
char* end = NULL;
const unsigned long degrees = strtoul(value, &end, 10);
while (end != NULL && (*end == ' ' || *end == '\t')) end++;
const bool valid = value[0] != 0 && end != NULL && *end == 0
&& (degrees == 0 || degrees == 90 || degrees == 180
|| degrees == 270);
if (!valid) {
snprintf(reply, reply_size,
"Error: use set display.rotation <0|90|180|270>");
} else if (_ui == NULL || !_ui->supportsDisplayRotation()) {
snprintf(reply, reply_size, "Error: display rotation is unsupported");
} else {
const uint16_t previous = _prefs.display_rotation_degrees;
if (!_ui->setDisplayRotationDegrees((uint16_t)degrees)) {
snprintf(reply, reply_size, "Error: display rotation failed");
} else {
_prefs.display_rotation_degrees = (uint16_t)degrees;
if (!savePrefs()) {
_prefs.display_rotation_degrees = previous;
_ui->setDisplayRotationDegrees(previous);
snprintf(reply, reply_size,
"Error: display rotation changed but save failed");
} else if (degrees == 0) {
snprintf(reply, reply_size,
"OK - display rotation reset to board default");
} else {
snprintf(reply, reply_size, "OK - display rotation %lu",
degrees);
}
}
}
return true;
}
if (strcmp(command, "get bluetooth.name") == 0
|| strcmp(command, "get ble.name") == 0) {
formatBluetoothNameStatus(reply, reply_size);
@@ -2074,11 +2134,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
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
uint8_t sf = 0, cr = 0;
uint32_t timeout_mins = 0;
if (!mesh::cli::parseTemporaryRadioTupleStrict(
command + 10, freq, bw, sf, cr, timeout_mins)
|| !isfinite(freq) || !isfinite(bw)
|| freq < 150.0f || freq > 2500.0f
|| !isFullCompanionBandwidth(bw)
@@ -2088,8 +2147,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
"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);
scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size);
return true;
}
@@ -2448,17 +2506,16 @@ void MyMesh::execCommand(char* cmd, char* reply) {
}
if (strcmp(key, "radio") == 0) {
float freq, bw;
int sf, cr;
char extra;
if (sscanf(value, "%f,%f,%d,%d%c", &freq, &bw, &sf, &cr, &extra) != 4
uint8_t sf, cr;
if (!mesh::cli::parseRadioTupleStrict(value, freq, bw, sf, cr)
|| !isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f
|| bw < 7.0f || bw > 500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8) {
strcpy(reply, "Error: radio must be freq,bw,sf,cr");
} else {
_prefs.freq = freq;
_prefs.bw = bw;
_prefs.sf = static_cast<uint8_t>(sf);
_prefs.cr = static_cast<uint8_t>(cr);
_prefs.sf = sf;
_prefs.cr = cr;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
@@ -3825,6 +3882,85 @@ void MyMesh::handleCmdFrame(size_t len) {
writeOKFrame();
}
}
} else if (cmd_frame[0]
== mesh::companion::CMD_EXEC_LOCAL_OTA_CONTROL) {
#if defined(COMPANION_RADIO_FULL)
const size_t command_len = len - 1;
if (!mesh::companion::isBleOtaControlCommandAllowed(
&cmd_frame[1], command_len)) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
char command[MAX_FRAME_SIZE] = {0};
memcpy(command, &cmd_frame[1], command_len);
char reply[MAX_FRAME_SIZE] = {0};
if (!handleLocalControlCommand(command, reply, sizeof(reply))) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
out_frame[0] = RESP_CODE_OK;
const size_t reply_len = strnlen(reply, MAX_FRAME_SIZE - 2);
out_frame[1] = static_cast<uint8_t>(reply_len);
memcpy(&out_frame[2], reply, reply_len);
_serial->writeFrame(out_frame, 2 + reply_len);
}
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
} else if (cmd_frame[0] == mesh::companion::CMD_BLE_MOTA_SOURCE) {
#if defined(COMPANION_RADIO_FULL)
if (len != 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (_mota_source_control == NULL) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
const mesh::companion::MotaSourceAction action =
static_cast<mesh::companion::MotaSourceAction>(cmd_frame[1]);
char control_reply[96] = {0};
bool action_ok = true;
if (action == mesh::companion::MotaSourceAction::Start) {
action_ok = _mota_source_control->start(control_reply,
sizeof(control_reply));
} else if (action == mesh::companion::MotaSourceAction::Stop) {
action_ok = _mota_source_control->stop(control_reply,
sizeof(control_reply));
} else if (action != mesh::companion::MotaSourceAction::Status) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
if (!action_ok) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
const mesh::companion::MotaSourceStatus status =
_mota_source_control->status();
uint8_t flags = 0;
if (status.channel_ready) {
flags |= mesh::companion::MOTA_SOURCE_FLAG_CHANNEL_READY;
}
if (status.attached) {
flags |= mesh::companion::MOTA_SOURCE_FLAG_ATTACHED;
}
if (status.another_link_active) {
flags |=
mesh::companion::MOTA_SOURCE_FLAG_ANOTHER_LINK_ACTIVE;
}
out_frame[0] = RESP_CODE_OK;
out_frame[1] = cmd_frame[1];
out_frame[2] = flags;
out_frame[3] = static_cast<uint8_t>(status.offered & 0xFF);
out_frame[4] = static_cast<uint8_t>(status.offered >> 8);
out_frame[5] = static_cast<uint8_t>(status.advertised & 0xFF);
out_frame[6] = static_cast<uint8_t>(status.advertised >> 8);
out_frame[7] = static_cast<uint8_t>(status.packets_sent & 0xFF);
out_frame[8] = static_cast<uint8_t>((status.packets_sent >> 8) & 0xFF);
out_frame[9] = static_cast<uint8_t>((status.packets_sent >> 16) & 0xFF);
out_frame[10] = static_cast<uint8_t>((status.packets_sent >> 24) & 0xFF);
_serial->writeFrame(out_frame, 11);
}
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
} else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) {
// FUTURE use: uint8_t reserved = cmd_frame[1];
uint8_t *pub_key = &cmd_frame[2];
@@ -5313,7 +5449,7 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().printf(
" OK - USB logging %s (saved); rebooting to change USB interfaces\r\n",
enabled ? "on" : "off");
_usb_logging_reboot_at = futureMillis(1000);
_scheduled_reboot_at = futureMillis(1000);
} else {
terminalOutput().printf(
" OK - USB logging %s (saved); reboot required to change USB interfaces\r\n",
@@ -5413,11 +5549,16 @@ void MyMesh::handleTerminalCommand(char* command) {
} else {
terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config);
}
} else if (strcmp(command, "reboot") == 0) {
terminalOutput().print(" OK - rebooting in 1 second\r\n");
_scheduled_reboot_at = futureMillis(1000);
} else if (strcmp(command, "ver") == 0) {
terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
} else if (strcmp(command, "help") == 0) {
terminalOutput().print("Commands:\r\n");
terminalOutput().print(" get display.rotation\r\n");
terminalOutput().print(" set display.rotation <0|90|180|270>\r\n");
terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
terminalOutput().print(" get bluetooth.name\r\n");
terminalOutput().print(" set bluetooth.name <name|default>\r\n");
@@ -5476,6 +5617,7 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" ota {status|ls|announce|folder|config|...}\r\n");
#endif
#endif
terminalOutput().print(" reboot\r\n");
terminalOutput().print(" ver\r\n");
if (_terminal_mode) {
terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n");
@@ -5700,14 +5842,12 @@ void MyMesh::checkSerialInterface() {
}
void MyMesh::loop() {
#if MESH_USB_LOGGING_AVAILABLE
if (_usb_logging_reboot_at != 0
&& millisHasNowPassed(_usb_logging_reboot_at)) {
_usb_logging_reboot_at = 0;
if (_scheduled_reboot_at != 0
&& millisHasNowPassed(_scheduled_reboot_at)) {
_scheduled_reboot_at = 0;
board.reboot();
return;
}
#endif
#if COMPANION_FEATURE_TEMP_RADIO
serviceTempRadio();
#endif
+9 -4
View File
@@ -6,7 +6,7 @@
#include "CompanionFeatures.h"
/*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 13
#define FIRMWARE_VER_CODE 14
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "14 Aug 2026"
@@ -40,6 +40,7 @@
#include <RTClib.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/BaseSerialInterface.h>
#include <helpers/CompanionMotaControl.h>
#include <helpers/IdentityStore.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/SimpleMeshTables.h>
@@ -137,6 +138,9 @@ public:
CompanionNodePrefs *getNodePrefs();
uint32_t getBLEPin();
int getOfflineQueueCapacity() const;
void setMotaSourceControl(mesh::companion::MotaSourceControl* control) {
_mota_source_control = control;
}
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void serviceMQTT(const char* wifi_ssid, const char* wifi_password);
@@ -397,6 +401,7 @@ private:
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
uint32_t pending_req; // pending _BINARY_REQ
BaseSerialInterface *_serial;
mesh::companion::MotaSourceControl* _mota_source_control;
AbstractUITask* _ui;
ContactsIterator _iter;
@@ -437,9 +442,9 @@ private:
uint8_t command_radio_cr;
uint8_t command_radio_repeat;
unsigned long command_radio_apply_deadline;
#if MESH_USB_LOGGING_AVAILABLE
unsigned long _usb_logging_reboot_at;
#endif
// Deferred so USB/TCP terminals can transmit the acknowledgement before
// the transport disappears. Also used by USB interface changes.
unsigned long _scheduled_reboot_at;
#if COMPANION_FEATURE_TEMP_RADIO
unsigned long _temp_radio_set_at;
unsigned long _temp_radio_revert_at;
+1
View File
@@ -57,6 +57,7 @@ struct CompanionNodePrefs { // persisted to file
uint8_t usb_logging_enabled; // live USB packet/debug output
char bluetooth_name[mesh::companion::BLUETOOTH_NAME_SIZE];
// exact BLE name; empty uses BLE_NAME_PREFIX + node_name
uint16_t display_rotation_degrees; // 0=board default; otherwise 90/180/270
// Keep the upstream repeat API while retaining the existing binary prefs
// layout used by this branch.
+120 -3
View File
@@ -147,6 +147,116 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
#endif
);
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
class Nrf52BleMotaSourceControl : public mesh::companion::MotaSourceControl {
public:
Nrf52BleMotaSourceControl()
: _source(bluetooth_interface.motaStream(),
mesh::ota::MotaStreamWritePolicy::NoFlush, 3000),
_packets_sent_at_start(0), _last_packets_sent(0) {}
bool start(char* reply, size_t reply_size) override {
if (!reply || reply_size == 0) return false;
if (!bluetooth_interface.isMotaChannelReady()) {
snprintf(reply, reply_size,
"ERR subscribe to the Bluetooth mOTA request characteristic first");
return false;
}
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
if (context.folder_active
&& context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE) {
snprintf(reply, reply_size, "ERR mOTA source already uses %s",
mesh::ota::OtaContext::folderLinkName(context.folderLink()));
return false;
}
_packets_sent_at_start = context.manager.packetsSent();
_last_packets_sent = 0;
bluetooth_interface.setMotaStreamActive(true);
if (!context.attach_folder_source(
&_source, mesh::ota::OtaContext::FOLDER_LINK_BLE, "ble",
reply, reply_size)) {
bluetooth_interface.setMotaStreamActive(false);
return false;
}
context.manager.announce();
mesh::usbLoggingPort().println("Bluetooth mOTA source attached");
return true;
}
bool stop(char* reply, size_t reply_size) override {
if (!reply || reply_size == 0) return false;
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
bluetooth_interface.setMotaStreamActive(false);
if (context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE) {
context.detach_folder();
context.manager.announce();
mesh::usbLoggingPort().println("Bluetooth mOTA source detached");
}
_last_packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped");
return true;
}
mesh::companion::MotaSourceStatus status() const override {
const mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
mesh::companion::MotaSourceStatus result;
result.channel_ready = bluetooth_interface.isMotaChannelReady();
result.attached = context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE
&& bluetooth_interface.isMotaStreamActive();
result.another_link_active = context.folder_active
&& context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE;
if (result.attached) {
context.folderSourceStats(result.offered, result.advertised);
result.packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
} else {
result.packets_sent = _last_packets_sent;
}
return result;
}
void loop() {
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
const bool owns_folder = context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE;
if (!owns_folder) {
if (bluetooth_interface.isMotaStreamActive()) {
bluetooth_interface.setMotaStreamActive(false);
}
return;
}
if (bluetooth_interface.isMotaChannelReady()
&& bluetooth_interface.isMotaStreamActive()) {
return;
}
bluetooth_interface.setMotaStreamActive(false);
context.detach_folder();
context.manager.announce();
_last_packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
mesh::usbLoggingPort().println(
"Bluetooth mOTA source disconnected and was detached");
}
private:
mesh::ota::SerialMotaSource _source;
uint32_t _packets_sent_at_start;
uint32_t _last_packets_sent;
};
static Nrf52BleMotaSourceControl ble_mota_source_control;
#endif
/* END GLOBAL OBJECTS */
#ifdef RECOVERABLE_EXTERNAL_RADIO
@@ -463,7 +573,7 @@ static void serviceUsbTerminal() {
// logs. `set usb.logging off` remains available here and returns this TTY to
// Binary Companion after its command reply, even on USB-UART bridges that
// cannot detect a host disconnect.
#if defined(COMPANION_RADIO_FULL)
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort()) {
if (mesh::isUsbLoggingEnabled()) {
if (!the_mesh.isTerminalMode()) {
@@ -522,7 +632,7 @@ static void serviceUsbTerminal() {
Serial.print("\r\n");
the_mesh.handleTerminalCommand(usb_terminal_line);
clearUsbTerminalLine();
#if defined(COMPANION_RADIO_FULL)
#if MESH_USB_LOGGING_AVAILABLE
if (usb_logging_terminal_mode
&& !mesh::isUsbLoggingEnabled()) {
leaveUsbTerminalMode(true);
@@ -1188,6 +1298,10 @@ void setup() {
#error "need to define filesystem"
#endif
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
the_mesh.setMotaSourceControl(&ble_mota_source_control);
#endif
// nRF52 cannot decide whether to add its optional logging CDC interface
// until the saved Companion preferences above are available. ESP32 already
// fixed its descriptor from the early NVS mirror, so this is harmless there.
@@ -1279,7 +1393,7 @@ void setup() {
usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; });
#endif
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
#if defined(COMPANION_RADIO_FULL)
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()) {
// Apply a saved single-TTY logging preference before the dispatcher can
@@ -1344,6 +1458,9 @@ void loop() {
serviceUsbTerminal();
#endif
interface_manager.loop();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
ble_mota_source_control.loop();
#endif
sensors.loop();
#ifdef DISPLAY_CLASS
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
@@ -854,6 +854,9 @@ public:
void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
#if defined(PIN_USER_BTN)
+6
View File
@@ -107,6 +107,12 @@ public:
int getMsgCount() const { return _msgcount; }
int getPreviewCount() const;
bool hasDisplay() const { return _display != NULL; }
bool supportsDisplayRotation() const override {
return _display != NULL && _display->supportsRotation();
}
bool setDisplayRotationDegrees(uint16_t degrees) override {
return _display != NULL && _display->setRotationDegrees(degrees);
}
bool isButtonPressed() const;
bool isBuzzerQuiet() {
@@ -46,6 +46,9 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
clearMsgPreview();
_node_prefs = node_prefs;
@@ -74,6 +74,12 @@ public:
void begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs);
bool hasDisplay() const { return _display != NULL; }
bool supportsDisplayRotation() const override {
return _display != NULL && _display->supportsRotation();
}
bool setDisplayRotationDegrees(uint16_t degrees) override {
return _display != NULL && _display->setRotationDegrees(degrees);
}
void clearMsgPreview();
// from AbstractUITask
@@ -437,6 +437,9 @@ public:
void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_cached_batt_mv = getBattMilliVolts();
@@ -89,6 +89,12 @@ public:
int getMsgCount() const { return _msgcount; }
uint16_t getCachedBattMV() const { return _cached_batt_mv; }
bool hasDisplay() const { return _display != NULL; }
bool supportsDisplayRotation() const override {
return _display != NULL && _display->supportsRotation();
}
bool setDisplayRotationDegrees(uint16_t degrees) override {
return _display != NULL && _display->setRotationDegrees(degrees);
}
bool isButtonPressed() const;
bool isBuzzerQuiet() {
+17 -2
View File
@@ -664,7 +664,7 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender
memcpy(&reply_data[4], &now, 4); // include our clock (for easy clock sync, and packet hash uniqueness)
reply_data[8] = 0; // features
#ifdef WITH_RS232_BRIDGE
reply_data[8] |= 0x01; // is bridge, type UART
if (_prefs.bridge_enabled) reply_data[8] |= 0x01; // is bridge, type UART
#elif WITH_ESPNOW_BRIDGE
reply_data[8] |= 0x03; // is bridge, type ESP-NOW
#endif
@@ -3161,7 +3161,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
#if defined(WITH_MQTT_BRIDGE)
, mqtt_bridge(nullptr)
#elif defined(WITH_RS232_BRIDGE)
, bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc)
, bridge(nullptr)
#elif defined(WITH_ESPNOW_BRIDGE)
, bridge(&_prefs, _mgr, &rtc)
#endif
@@ -3335,11 +3335,21 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
&_prefs.rx_ps_sleep_us);
// bridge defaults
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
_prefs.bridge_enabled = 0; // normal repeater until explicitly enabled
#else
_prefs.bridge_enabled = 1; // enabled
#endif
_prefs.bridge_delay = 500; // milliseconds
_prefs.bridge_pkt_src = 1; // logRx (RX packets)
_prefs.bridge_baud = 115200; // baud rate
_prefs.bridge_channel = 1; // channel 1
#ifdef WITH_RS232_BRIDGE
_prefs.bridge_uart = WITH_RS232_BRIDGE_UART;
#else
_prefs.bridge_uart = 0;
#endif
StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret));
@@ -3470,6 +3480,11 @@ void MyMesh::begin(FILESYSTEM *fs) {
node_info.repeat_when_nonzero = false;
mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
#endif
#ifdef WITH_RS232_BRIDGE
if (!bridge) {
bridge = createRS232Bridge();
}
#endif
AbstractBridge* active_bridge = activeBridge();
if (active_bridge) {
+46 -2
View File
@@ -493,7 +493,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#if defined(WITH_MQTT_BRIDGE)
MQTTBridge* mqtt_bridge;
#elif defined(WITH_RS232_BRIDGE)
RS232Bridge bridge;
RS232Bridge* bridge;
#elif defined(WITH_ESPNOW_BRIDGE)
ESPNowBridge bridge;
#endif
@@ -501,6 +501,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
AbstractBridge* activeBridge() {
#ifdef WITH_MQTT_BRIDGE
return mqtt_bridge;
#elif defined(WITH_RS232_BRIDGE)
return bridge;
#else
return &bridge;
#endif
@@ -508,6 +510,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
const AbstractBridge* activeBridge() const {
#ifdef WITH_MQTT_BRIDGE
return mqtt_bridge;
#elif defined(WITH_RS232_BRIDGE)
return bridge;
#else
return &bridge;
#endif
@@ -975,6 +979,21 @@ public:
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
#if defined(WITH_BRIDGE)
#ifdef WITH_RS232_BRIDGE
RS232Bridge* createRS232Bridge() {
#ifdef WITH_RS232_BRIDGE_ALT
if (_prefs.bridge_uart == WITH_RS232_BRIDGE_ALT_UART) {
return new RS232Bridge(&_prefs, WITH_RS232_BRIDGE_ALT,
WITH_RS232_BRIDGE_ALT_RX,
WITH_RS232_BRIDGE_ALT_TX, _mgr, getRTCClock());
}
#endif
return new RS232Bridge(&_prefs, WITH_RS232_BRIDGE,
WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX,
_mgr, getRTCClock());
}
#endif
void setBridgeState(bool enable) override {
#ifdef WITH_MQTT_BRIDGE
if (!mqtt_bridge) {
@@ -990,9 +1009,24 @@ public:
getRTCClock(), &self_id);
if (!mqtt_bridge) return;
}
#endif
#ifdef WITH_RS232_BRIDGE
if (enable && !bridge) {
bridge = createRS232Bridge();
if (!bridge) return;
}
#endif
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || enable == active_bridge->isRunning()) return;
if (!active_bridge) return;
if (enable == active_bridge->isRunning()) {
#ifdef WITH_RS232_BRIDGE
if (!enable) {
delete bridge;
bridge = nullptr;
}
#endif
return;
}
if (enable)
{
#ifdef WITH_MQTT_BRIDGE
@@ -1014,6 +1048,10 @@ public:
else
{
active_bridge->end();
#ifdef WITH_RS232_BRIDGE
delete bridge;
bridge = nullptr;
#endif
#ifdef WITH_MQTT_BRIDGE
_alerter.setBridge(nullptr);
#endif
@@ -1030,6 +1068,12 @@ public:
}
#endif
active_bridge->end();
#ifdef WITH_RS232_BRIDGE
delete bridge;
bridge = createRS232Bridge();
if (bridge) bridge->begin();
return;
#endif
#ifdef WITH_MQTT_BRIDGE
// Set device metadata before restarting bridge (same as in begin())
char device_id[65];
+35 -1
View File
@@ -1,5 +1,8 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
#if defined(NRF52_PLATFORM)
#include <InternalFileSystem.h>
@@ -72,6 +75,7 @@ struct NodePrefs { // persisted to file
float freq;
int8_t tx_power_dbm;
uint8_t unused[3];
uint8_t usb_logging_enabled;
};
class MyMesh : public BaseChatMesh, ContactVisitor {
@@ -293,6 +297,7 @@ public:
strcpy(_prefs.node_name, "NONAME");
_prefs.freq = LORA_FREQ;
_prefs.tx_power_dbm = LORA_TX_POWER;
_prefs.usb_logging_enabled = 1;
command[0] = 0;
curr_recipient = NULL;
@@ -357,6 +362,11 @@ public:
}
}
#if MESH_USB_LOGGING_AVAILABLE
_prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 1);
mesh::setUsbLoggingEnabled(_prefs.usb_logging_enabled != 0);
#endif
loadContacts();
_public = addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
}
@@ -493,9 +503,28 @@ public:
}
} else if (memcmp(command, "import ", 7) == 0) {
importCard(&command[7]);
} else if (strcmp(command, "get usb.logging") == 0) {
#if MESH_USB_LOGGING_AVAILABLE
Serial.printf(" usb.logging %s\n",
mesh::isUsbLoggingEnabled() ? "on" : "off");
#else
Serial.println(" ERROR: USB logging is unavailable");
#endif
} else if (memcmp(command, "set ", 4) == 0) {
const char* config = &command[4];
if (memcmp(config, "af ", 3) == 0) {
if (strcmp(config, "usb.logging on") == 0
|| strcmp(config, "usb.logging off") == 0) {
#if MESH_USB_LOGGING_AVAILABLE
const bool enabled = strcmp(config, "usb.logging on") == 0;
_prefs.usb_logging_enabled = enabled ? 1 : 0;
mesh::setUsbLoggingEnabled(enabled);
savePrefs();
Serial.printf(" OK - USB logging %s (saved)\n",
enabled ? "on" : "off");
#else
Serial.println(" ERROR: USB logging is unavailable");
#endif
} else if (memcmp(config, "af ", 3) == 0) {
_prefs.airtime_factor = atof(&config[3]);
savePrefs();
Serial.println(" OK");
@@ -527,6 +556,8 @@ public:
} else if (memcmp(command, "help", 4) == 0) {
Serial.println("Commands:");
Serial.println(" set {name|lat|lon|freq|tx|af} {value}");
Serial.println(" get usb.logging");
Serial.println(" set usb.logging {on|off}");
Serial.println(" card");
Serial.println(" import {biz card}");
Serial.println(" clock");
@@ -578,6 +609,9 @@ void halt() {
void setup() {
Serial.begin(115200);
#if MESH_PACKET_LOGGING
mesh::serialLogBegin();
#endif
board.begin();
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ota {
// MeshCore BLE mOTA seeder service. The device notifies one framed seeder
// request on REQUEST; the paired host writes the framed response to RESPONSE.
// Both characteristics require an encrypted MITM-authenticated BLE link.
static constexpr char BLE_MOTA_SERVICE_UUID[] =
"14518fc2-7e7a-4d84-8cae-6664b0234cf2";
static constexpr char BLE_MOTA_REQUEST_UUID[] =
"2bfaa1ee-7030-459a-b65a-e7cfd5b09735";
static constexpr char BLE_MOTA_RESPONSE_UUID[] =
"acf38a51-dd58-4dce-917f-0b1135e41b1a";
static constexpr uint16_t BLE_MOTA_REQUEST_MAX = 11;
static constexpr uint16_t BLE_MOTA_RESPONSE_MAX = 197;
} // namespace ota
} // namespace mesh
+163
View File
@@ -0,0 +1,163 @@
#pragma once
#include <Arduino.h>
#include <atomic>
#include <stddef.h>
#include <stdint.h>
namespace mesh {
namespace ota {
// A single-producer/single-consumer Stream adapter for the nRF52 BLE mOTA
// GATT service. The BLE callback appends host response fragments while the
// mesh loop synchronously consumes them through SerialMotaSource. Device
// requests travel in the other direction through the bounded send callback.
//
// One seeder response is at most 197 bytes. Keeping 256 bytes here holds a
// complete response while still failing closed on duplicate or injected data.
class BleMotaStream : public Stream {
public:
static constexpr uint16_t RX_CAPACITY = 256;
using SendCallback = size_t (*)(void* context, const uint8_t* data,
size_t length);
BleMotaStream() = default;
void setSender(SendCallback callback, void* context) {
_send_context = context;
_send = callback;
}
void setActive(bool active) {
_active.store(false, std::memory_order_release);
if (active) {
clear();
} else {
invalidateWriters();
}
_overflowed.store(false, std::memory_order_release);
_active.store(active, std::memory_order_release);
}
bool isActive() const {
return _active.load(std::memory_order_acquire);
}
bool overflowed() const {
return _overflowed.load(std::memory_order_acquire);
}
// Called only by the BLE response-characteristic callback. A fragment is
// accepted atomically or rejected in full; a partial frame is never queued.
bool pushRx(const uint8_t* data, size_t length) {
if (!isActive() || data == nullptr || length == 0
|| length >= RX_CAPACITY) {
return false;
}
const uint32_t head_state = _head_state.load(std::memory_order_acquire);
const uint16_t head = static_cast<uint16_t>(head_state);
const uint16_t tail = _tail.load(std::memory_order_acquire);
const uint16_t used = head >= tail ? head - tail
: RX_CAPACITY - (tail - head);
const uint16_t free_bytes = RX_CAPACITY - used - 1;
if (length > free_bytes) {
_overflowed.store(true, std::memory_order_release);
return false;
}
uint16_t cursor = head;
for (size_t i = 0; i < length; ++i) {
_rx[cursor] = data[i];
cursor = static_cast<uint16_t>((cursor + 1) % RX_CAPACITY);
}
const uint32_t next_state = (head_state & 0xFFFF0000UL) | cursor;
uint32_t expected = head_state;
// setActive() changes the generation, and a new session also resets the
// head. The CAS ensures a callback which overlapped either transition
// cannot publish stale bytes into the next source session, even when both
// sessions happened to use the same ring index.
return isActive()
&& _head_state.compare_exchange_strong(
expected, next_state, std::memory_order_release,
std::memory_order_relaxed);
}
int available() override {
if (!isActive()) return 0;
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
const uint16_t tail = _tail.load(std::memory_order_relaxed);
return head >= tail ? head - tail : RX_CAPACITY - (tail - head);
}
int read() override {
if (!isActive()) return -1;
const uint16_t tail = _tail.load(std::memory_order_relaxed);
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
if (tail == head) return -1;
const uint8_t value = _rx[tail];
_tail.store(static_cast<uint16_t>((tail + 1) % RX_CAPACITY),
std::memory_order_release);
return value;
}
int peek() override {
if (!isActive()) return -1;
const uint16_t tail = _tail.load(std::memory_order_relaxed);
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
return tail == head ? -1 : _rx[tail];
}
void flush() override {}
size_t write(uint8_t value) override {
return write(&value, 1);
}
size_t write(const uint8_t* data, size_t length) override {
if (!isActive() || data == nullptr || length == 0 || _send == nullptr) {
return 0;
}
return _send(_send_context, data, length);
}
using Print::write;
private:
static uint16_t nextGeneration(uint32_t state) {
return static_cast<uint16_t>(state >> 16) + 1;
}
void invalidateWriters() {
const uint32_t previous = _head_state.load(std::memory_order_relaxed);
const uint32_t next =
(static_cast<uint32_t>(nextGeneration(previous)) << 16)
| static_cast<uint16_t>(previous);
_head_state.store(next, std::memory_order_release);
}
void clear() {
const uint32_t previous = _head_state.load(std::memory_order_relaxed);
_head_state.store(
static_cast<uint32_t>(nextGeneration(previous)) << 16,
std::memory_order_release);
_tail.store(0, std::memory_order_release);
}
uint8_t _rx[RX_CAPACITY] = {};
// High 16 bits are a source-session generation; low 16 bits are the ring
// head. Packing both into one lock-free nRF52840 atomic prevents an ABA
// commit across clear()/restart.
std::atomic<uint32_t> _head_state{0};
std::atomic<uint16_t> _tail{0};
std::atomic<bool> _active{false};
std::atomic<bool> _overflowed{false};
SendCallback _send = nullptr;
void* _send_context = nullptr;
};
} // namespace ota
} // namespace mesh
+93
View File
@@ -588,5 +588,98 @@ inline bool parseIntegerStrict(const char* text, int32_t& result) {
return true;
}
inline bool splitCommaFieldsStrict(const char* text, char* storage,
size_t storage_size,
const char** fields,
size_t field_count) {
if (text == nullptr || storage == nullptr || storage_size == 0
|| fields == nullptr || field_count == 0) {
return false;
}
const size_t length = strlen(text);
if (length == 0 || length >= storage_size) return false;
memcpy(storage, text, length + 1);
char* cursor = storage;
for (size_t i = 0; i < field_count; ++i) {
fields[i] = cursor;
char* separator = strchr(cursor, ',');
if (i + 1 == field_count) {
if (separator != nullptr) return false;
} else {
if (separator == nullptr) return false;
*separator = 0;
cursor = separator + 1;
}
}
return true;
}
// Parse the radio tuple syntax without libc's optional float-scanf support.
// In particular, newlib-nano nRF52 builds commonly compile `%f` but return no
// conversions unless the much larger _scanf_float implementation is linked.
inline bool parseRadioTupleStrict(const char* text, float& frequency,
float& bandwidth, uint8_t& spreading_factor,
uint8_t& coding_rate) {
char storage[96] = {0};
const char* fields[4] = {nullptr};
if (!splitCommaFieldsStrict(text, storage, sizeof(storage), fields, 4)) {
return false;
}
float parsed_frequency = 0.0f;
float parsed_bandwidth = 0.0f;
int32_t parsed_sf = 0;
int32_t parsed_cr = 0;
if (!parseDecimalStrict(fields[0], parsed_frequency)
|| !parseDecimalStrict(fields[1], parsed_bandwidth)
|| !parseIntegerStrict(fields[2], parsed_sf)
|| !parseIntegerStrict(fields[3], parsed_cr)
|| parsed_sf < 0 || parsed_sf > 255
|| parsed_cr < 0 || parsed_cr > 255) {
return false;
}
frequency = parsed_frequency;
bandwidth = parsed_bandwidth;
spreading_factor = static_cast<uint8_t>(parsed_sf);
coding_rate = static_cast<uint8_t>(parsed_cr);
return true;
}
inline bool parseTemporaryRadioTupleStrict(
const char* text, float& frequency, float& bandwidth,
uint8_t& spreading_factor, uint8_t& coding_rate,
uint32_t& timeout_minutes) {
char storage[96] = {0};
const char* fields[5] = {nullptr};
if (!splitCommaFieldsStrict(text, storage, sizeof(storage), fields, 5)) {
return false;
}
float parsed_frequency = 0.0f;
float parsed_bandwidth = 0.0f;
int32_t parsed_sf = 0;
int32_t parsed_cr = 0;
int32_t parsed_timeout = 0;
if (!parseDecimalStrict(fields[0], parsed_frequency)
|| !parseDecimalStrict(fields[1], parsed_bandwidth)
|| !parseIntegerStrict(fields[2], parsed_sf)
|| !parseIntegerStrict(fields[3], parsed_cr)
|| !parseIntegerStrict(fields[4], parsed_timeout)
|| parsed_sf < 0 || parsed_sf > 255
|| parsed_cr < 0 || parsed_cr > 255
|| parsed_timeout < 0) {
return false;
}
frequency = parsed_frequency;
bandwidth = parsed_bandwidth;
spreading_factor = static_cast<uint8_t>(parsed_sf);
coding_rate = static_cast<uint8_t>(parsed_cr);
timeout_minutes = static_cast<uint32_t>(parsed_timeout);
return true;
}
} // namespace cli
} // namespace mesh
+96 -6
View File
@@ -768,6 +768,11 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_prefs->system_watchdog_enabled = 1;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_prefs->usb_logging_enabled = 1;
#ifdef WITH_RS232_BRIDGE
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
#else
_prefs->bridge_uart = 0;
#endif
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
@@ -898,6 +903,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
File file = fs->open(filename);
#endif
if (file) {
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
bool has_runtime_bridge_uart = false;
#endif
// Every supported layout contains the fixed 290-byte common core. Reject
// a truncated in-place write before it can leave strings unterminated or
// feed partial radio values into startup. loadPrefs() rewrites the image.
@@ -1257,6 +1265,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->usb_logging_enabled)) {
file.read((uint8_t *)&_prefs->usb_logging_enabled,
sizeof(_prefs->usb_logging_enabled));
if (file.available() >= (int)sizeof(_prefs->bridge_uart)) {
file.read((uint8_t *)&_prefs->bridge_uart,
sizeof(_prefs->bridge_uart));
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
has_runtime_bridge_uart = true;
#endif
}
}
}
} else if (file.available() > 0) {
@@ -1314,11 +1329,32 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT
// sanitise bad bridge pref values
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
if (!has_runtime_bridge_uart) {
// Pre-merge normal repeaters persisted bridge_enabled=1 even though no
// bridge was compiled. Fail safe on the first merged boot instead of
// unexpectedly claiming a UART; the user can explicitly enable it.
_prefs->bridge_enabled = 0;
_com_prefs_needs_upgrade = true;
}
#endif
_prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1);
_prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000);
_prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1);
_prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD);
_prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14);
#ifdef WITH_RS232_BRIDGE
if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
&& _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART
#endif
) {
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
_com_prefs_needs_upgrade = true;
}
#else
_prefs->bridge_uart = 0;
#endif
_prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1);
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
@@ -1538,6 +1574,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->extra_sf); // 856
WRITE_COMMON_PREFS(&prefs->radio_fem_txgain); // 860
WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861
WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
@@ -1690,7 +1727,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)_prefs->extra_sf, sizeof(_prefs->extra_sf)); // 856
file.write((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 860
file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861
// next: 862
file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862
// next: 863
#if defined(NRF52_PLATFORM)
if (!file.commit()) {
@@ -2538,7 +2576,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "gps_interval must be 0..86400 seconds");
} else if (!valid_gps_toggle) {
strcpy(reply, "gps must be 0 or 1");
} else if (_sensors->setSettingValue(key, value)) {
}
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
else if (is_gps_toggle && strcmp(value, "1") == 0
&& _prefs->bridge_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "turn the RS232 bridge off or select another UART first");
}
#endif
else if (_sensors->setSettingValue(key, value)) {
if (is_gps_interval) {
_prefs->gps_interval = gps_interval;
savePrefs();
@@ -2579,6 +2625,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
handleRegionCmd(command, reply);
#if ENV_INCLUDE_GPS == 1
} else if (memcmp(command, "gps on", 6) == 0) {
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
if (_prefs->bridge_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "turn the RS232 bridge off or select another UART first");
} else
#endif
if (_sensors->setSettingValue("gps", "1")) {
_prefs->gps_enabled = 1;
savePrefs();
@@ -4019,10 +4071,19 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
}
#ifdef WITH_BRIDGE
} else if (memcmp(config, "bridge.enabled ", 15) == 0) {
_prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0;
_callbacks->setBridgeState(_prefs->bridge_enabled);
savePrefs();
strcpy(reply, "OK");
const bool enable = memcmp(&config[15], "on", 2) == 0;
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
if (enable && _prefs->gps_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "Error: turn GPS off or select another UART first");
} else
#endif
{
_prefs->bridge_enabled = enable;
_callbacks->setBridgeState(_prefs->bridge_enabled);
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "bridge.delay ", 13) == 0) {
int delay = _atoi(&config[13]);
if (delay >= 0 && delay <= 10000) {
@@ -4059,6 +4120,33 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
}
} else if (memcmp(config, "bridge.uart ", 12) == 0) {
const int uart = atoi(&config[12]);
const bool supported = uart == WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
|| uart == WITH_RS232_BRIDGE_ALT_UART
#endif
;
if (!supported) {
#ifdef WITH_RS232_BRIDGE_ALT
sprintf(reply, "Error: UART must be %d or %d",
WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART);
#else
sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART);
#endif
}
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
else if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART
&& _prefs->gps_enabled && _prefs->bridge_enabled) {
strcpy(reply, "Error: turn GPS or the bridge off first");
}
#endif
else {
_prefs->bridge_uart = (uint8_t)uart;
_callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
}
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel ", 15) == 0) {
@@ -4453,6 +4541,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#ifdef WITH_RS232_BRIDGE
} else if (memcmp(config, "bridge.baud", 11) == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud);
} else if (memcmp(config, "bridge.uart", 11) == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_uart);
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel", 14) == 0) {
+10 -2
View File
@@ -25,6 +25,10 @@
#define WITH_BRIDGE
#endif
#if defined(WITH_RS232_BRIDGE) && !defined(WITH_RS232_BRIDGE_UART)
#define WITH_RS232_BRIDGE_UART 1
#endif
#define ADVERT_LOC_NONE 0
#define ADVERT_LOC_SHARE 1
#define ADVERT_LOC_PREFS 2
@@ -169,9 +173,12 @@ public:
uint8_t extra_sf[4] = {};
uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain; persisted at /com_prefs offset 860
// Runtime USB packet output gate. Appended at /com_prefs offset 861 so
// older images remain readable and logging builds keep their historical
// enabled-at-first-boot behavior.
// older images remain readable and merged standard builds keep the former
// logging artifact's enabled-at-first-boot behavior.
uint8_t usb_logging_enabled = 1;
// Runtime UART choice for merged RS-232 repeater artifacts. Appended at
// /com_prefs offset 862; single-UART builds keep their compiled port here.
uint8_t bridge_uart = 0;
uint8_t retry_preset = 0;
uint8_t direct_retry_attempts = 0;
uint16_t direct_retry_base_ms = 0;
@@ -267,6 +274,7 @@ private:
def("delay", _parent->bridge_delay);
def("src", _parent->bridge_pkt_src);
def("baud", _parent->bridge_baud);
def("uart", _parent->bridge_uart);
def("ch", _parent->bridge_channel);
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
def("usb_log", _parent->usb_logging_enabled);
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh {
namespace companion {
static constexpr uint8_t CMD_EXEC_LOCAL_OTA_CONTROL = 0x4A;
static constexpr uint8_t CMD_BLE_MOTA_SOURCE = 0x4B;
enum class MotaSourceAction : uint8_t {
Status = 0,
Start = 1,
Stop = 2,
};
static constexpr uint8_t MOTA_SOURCE_FLAG_CHANNEL_READY = 0x01;
static constexpr uint8_t MOTA_SOURCE_FLAG_ATTACHED = 0x02;
static constexpr uint8_t MOTA_SOURCE_FLAG_ANOTHER_LINK_ACTIVE = 0x04;
struct MotaSourceStatus {
bool channel_ready = false;
bool attached = false;
bool another_link_active = false;
uint16_t offered = 0;
uint16_t advertised = 0;
uint32_t packets_sent = 0;
};
class MotaSourceControl {
public:
virtual ~MotaSourceControl() = default;
virtual bool start(char* reply, size_t reply_size) = 0;
virtual bool stop(char* reply, size_t reply_size) = 0;
virtual MotaSourceStatus status() const = 0;
};
// Bluetooth may expose only the local commands needed to coordinate a LoRa
// mOTA session. The command arrives as one length-delimited Companion frame,
// so reject embedded NUL/control bytes and USB folder ownership commands.
inline bool isBleOtaControlCommandAllowed(const uint8_t* command,
size_t length) {
if (command == nullptr || length == 0 || length > 174) return false;
for (size_t i = 0; i < length; ++i) {
if (command[i] < 0x20 || command[i] > 0x7E) return false;
switch (command[i]) {
case ';':
case '&':
case '|':
case '`':
case '$':
case '\\':
case '\'':
case '"':
return false;
default:
break;
}
}
const auto exact = [command, length](const char* text, size_t text_length) {
if (length != text_length) return false;
for (size_t i = 0; i < length; ++i) {
if (command[i] != static_cast<uint8_t>(text[i])) return false;
}
return true;
};
const auto token = [command, length](const char* text, size_t text_length) {
if (length < text_length) return false;
for (size_t i = 0; i < text_length; ++i) {
if (command[i] != static_cast<uint8_t>(text[i])) return false;
}
return length == text_length || command[text_length] == ' ';
};
if (exact("normalradio", 11)) return true;
// Bare `tempradio` is a read-only status query. Keep the parameterized
// form strict, but let a BLE controller prove that cleanup completed.
if (exact("tempradio", 9)) return true;
if (token("tempradio", 9)) return length > 10;
if (!token("ota", 3)) return false;
if (token("ota folder", 10)) return false;
return true;
}
} // namespace companion
} // namespace mesh
+2 -2
View File
@@ -34,8 +34,8 @@
namespace mesh {
#if defined(COMPANION_RADIO_FULL)
// Full Companion owns its primary USB stream for framed traffic until saved
#if defined(ENABLE_USB_INTERFACE)
// A USB Companion owns its primary stream for framed traffic until saved
// preferences are loaded. Starting disabled prevents early boot diagnostics
// from corrupting that stream before single-TTY builds can enter terminal mode.
static std::atomic<bool> usb_logging_enabled{false};
+5 -5
View File
@@ -1,8 +1,8 @@
#pragma once
// Logging artifacts compile at least one of these two diagnostics. Keep the
// command surface out of ordinary images while providing one runtime gate for
// every diagnostic category that writes to the USB Serial stream.
// Canonical USB-capable images compile at least one of these two diagnostics.
// One runtime gate covers every diagnostic category that writes to the USB
// Serial stream, so a separate logging firmware is unnecessary.
#if defined(ARDUINO) && \
((defined(MESH_DEBUG) && MESH_DEBUG) || \
(defined(MESH_PACKET_LOGGING) && MESH_PACKET_LOGGING))
@@ -16,8 +16,8 @@
namespace mesh {
// Ordinary logging images start enabled. Every Full Companion starts with
// logging disabled and restores its saved choice after preferences load.
// Ordinary merged images start enabled. USB/Full Companion starts disabled to
// protect framed traffic and restores its saved choice after preferences load.
bool isUsbLoggingEnabled();
void setUsbLoggingEnabled(bool enabled);
+6
View File
@@ -251,6 +251,12 @@ inline bool isFirmwareReserved(uint8_t pin) {
#ifdef WITH_RS232_BRIDGE_TX
WITH_RS232_BRIDGE_TX,
#endif
#ifdef WITH_RS232_BRIDGE_ALT_RX
WITH_RS232_BRIDGE_ALT_RX,
#endif
#ifdef WITH_RS232_BRIDGE_ALT_TX
WITH_RS232_BRIDGE_ALT_TX,
#endif
#ifdef PIN_SERIAL_RX
PIN_SERIAL_RX,
#endif
+11 -13
View File
@@ -5,30 +5,28 @@
#ifdef WITH_RS232_BRIDGE
RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _serial(&serial) {}
RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin,
int16_t tx_pin, mesh::PacketManager *mgr,
mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _serial(&serial), _rx_pin(rx_pin),
_tx_pin(tx_pin) {}
void RS232Bridge::begin() {
BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud);
#if !defined(WITH_RS232_BRIDGE_RX) || !defined(WITH_RS232_BRIDGE_TX)
#error "WITH_RS232_BRIDGE_RX and WITH_RS232_BRIDGE_TX must be defined"
#endif
#if defined(ESP32)
((HardwareSerial *)_serial)->setPins(WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX);
((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin);
#elif defined(NRF52_PLATFORM)
// Tested with RAK_4631 and T114
// The Adafruit Uart object may already be active on its variant defaults.
// Stop it before changing pins or the EasyDMA instance can retain the old
// pin selection and silently receive nothing.
mesh::bridge::prepareNrfUart(*((Uart *)_serial),
WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX);
mesh::bridge::prepareNrfUart(*((Uart *)_serial), _rx_pin, _tx_pin);
#elif defined(RP2040_PLATFORM)
((SerialUART *)_serial)->setRX(WITH_RS232_BRIDGE_RX);
((SerialUART *)_serial)->setTX(WITH_RS232_BRIDGE_TX);
((SerialUART *)_serial)->setRX(_rx_pin);
((SerialUART *)_serial)->setTX(_tx_pin);
#elif defined(STM32_PLATFORM)
((HardwareSerial *)_serial)->setRx(WITH_RS232_BRIDGE_RX);
((HardwareSerial *)_serial)->setTx(WITH_RS232_BRIDGE_TX);
((HardwareSerial *)_serial)->setRx(_rx_pin);
((HardwareSerial *)_serial)->setTx(_tx_pin);
#else
#error RS232Bridge was not tested on the current platform
#endif
+6 -5
View File
@@ -18,7 +18,7 @@
* - Fletcher-16 checksum for data integrity verification
* - Magic header for packet synchronization and frame alignment
* - Duplicate packet detection using SimpleMeshTables tracking
* - Configurable RX/TX pins via build defines
* - Configurable UART and RX/TX pins selected by the owning runtime
* - Fixed baud rate at 115200 for consistent timing
*
* Packet Structure:
@@ -34,8 +34,7 @@
*
* Configuration:
* - Define WITH_RS232_BRIDGE to enable this bridge
* - Define WITH_RS232_BRIDGE_RX with the RX pin number
* - Define WITH_RS232_BRIDGE_TX with the TX pin number
* - The owning role supplies the selected UART and pins to the constructor
*
* Platform Support:
* Different platforms require different pin configuration methods:
@@ -54,12 +53,12 @@ public:
* @param mgr PacketManager for allocating and queuing packets
* @param rtc RTCClock for timestamping debug messages
*/
RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc);
RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, int16_t tx_pin,
mesh::PacketManager *mgr, mesh::RTCClock *rtc);
/**
* Initializes the RS232 bridge
*
* - Validates that RX/TX pins are defined
* - Configures UART pins based on target platform
* - Sets baud rate to 115200 for consistent communication
* - Platform-specific pin configuration methods are used
@@ -137,6 +136,8 @@ private:
/** Hardware serial port interface */
Stream *_serial;
int16_t _rx_pin;
int16_t _tx_pin;
/** Buffer for building received packets */
uint8_t _rx_buffer[MAX_SERIAL_PACKET_SIZE];
+7
View File
@@ -67,9 +67,16 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
BLESecurity sec;
#if defined(CONFIG_NIMBLE_ENABLED)
sec.setPassKey(true, pin_code);
// A passkey alone does not provide MITM protection when the controller's
// default capability is NoInputNoOutput: the peers can silently fall back
// to Just Works and create an unauthenticated bond. The Companion displays
// (or otherwise publishes) this PIN for entry on the phone/host, so declare
// the peripheral as DisplayOnly and force the passkey-entry association.
sec.setCapability(ESP_IO_CAP_OUT);
sec.setAuthenticationMode(true, true, true);
#else
sec.setStaticPIN(pin_code);
sec.setCapability(ESP_IO_CAP_OUT);
sec.setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND);
#endif
+84
View File
@@ -40,6 +40,9 @@ void SerialBLEInterface::onConnect(uint16_t connection_handle) {
instance->_isDeviceConnected = false;
instance->_security_timer.start(millis());
instance->clearBuffers();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
instance->setMotaStreamActive(false);
#endif
}
}
@@ -52,10 +55,46 @@ void SerialBLEInterface::onDisconnect(uint16_t connection_handle, uint8_t reason
instance->_isDeviceConnected = false;
instance->_security_timer.cancel();
instance->clearBuffers();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
instance->setMotaStreamActive(false);
#endif
}
}
}
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
void SerialBLEInterface::onMotaResponse(
uint16_t conn_handle, BLECharacteristic* characteristic,
uint8_t* data, uint16_t length) {
if (!instance || characteristic != &instance->_mota_response
|| instance->_conn_handle != conn_handle || !instance->isConnected()
|| !instance->_mota_stream.isActive()) {
return;
}
if (length == 0 || length > mesh::ota::BLE_MOTA_RESPONSE_MAX
|| !instance->_mota_stream.pushRx(data, length)) {
// A response that cannot fit intact would make the byte stream ambiguous.
// Disable the link so the current transaction times out and the main loop
// detaches it instead of consuming a partial or injected frame.
instance->setMotaStreamActive(false);
}
}
size_t SerialBLEInterface::sendMotaRequest(void* context,
const uint8_t* data,
size_t length) {
SerialBLEInterface* self = static_cast<SerialBLEInterface*>(context);
if (!self || !data || length == 0
|| length > mesh::ota::BLE_MOTA_REQUEST_MAX
|| !self->isMotaChannelReady() || !self->_mota_stream.isActive()) {
return 0;
}
return self->_mota_request.notify(self->_conn_handle, data, length)
? length : 0;
}
#endif
void SerialBLEInterface::onSecured(uint16_t connection_handle) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: onSecured handle=0x%04X", connection_handle);
if (instance) {
@@ -272,6 +311,38 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
bleuart.begin();
bleuart.setRxCallback(onBleUartRX);
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
_mota_stream.setSender(sendMotaRequest, this);
_mota_stream.setActive(false);
_mota_service.setPermission(SECMODE_ENC_WITH_MITM,
SECMODE_ENC_WITH_MITM);
if (_mota_service.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA service begin failed");
return false;
}
_mota_request.setProperties(CHR_PROPS_NOTIFY);
_mota_request.setPermission(SECMODE_ENC_WITH_MITM,
SECMODE_NO_ACCESS);
_mota_request.setMaxLen(mesh::ota::BLE_MOTA_REQUEST_MAX);
_mota_request.setUserDescriptor("mOTA device request");
if (_mota_request.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA request characteristic begin failed");
return false;
}
_mota_response.setProperties(CHR_PROPS_WRITE);
_mota_response.setPermission(SECMODE_NO_ACCESS,
SECMODE_ENC_WITH_MITM);
_mota_response.setMaxLen(mesh::ota::BLE_MOTA_RESPONSE_MAX);
_mota_response.setUserDescriptor("mOTA host response");
_mota_response.setWriteCallback(onMotaResponse);
if (_mota_response.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA response characteristic begin failed");
return false;
}
#endif
// Register DFU on the main BLE stack so paired clients can discover it
@@ -390,6 +461,9 @@ void SerialBLEInterface::recoverStalledTx(const char* cause) {
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
bleuart.flush();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
setMotaStreamActive(false);
#endif
_tx_disconnect_recovery.begin();
serviceTxRecovery((uint32_t)millis());
}
@@ -442,6 +516,9 @@ void SerialBLEInterface::disable() {
disconnect();
_security_timer.cancel();
_last_health_check = 0;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
setMotaStreamActive(false);
#endif
}
size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
@@ -610,6 +687,13 @@ bool SerialBLEInterface::isConnected() const {
Bluefruit.connected() > 0;
}
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
bool SerialBLEInterface::isMotaChannelReady() {
return isConnected() && _conn_handle != BLE_CONN_HANDLE_INVALID
&& _mota_request.notifyEnabled(_conn_handle);
}
#endif
bool SerialBLEInterface::isReadBusy() const {
return (recv_queue_len > 0);
}
+25
View File
@@ -6,6 +6,10 @@
#include "SecuritySessionTimer.h"
#include <bluefruit.h>
#include <atomic>
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
#include "../BleMotaProtocol.h"
#include "../BleMotaStream.h"
#endif
#ifndef BLE_TX_POWER
#define BLE_TX_POWER 4
@@ -14,6 +18,14 @@
class SerialBLEInterface : public BaseSerialInterface {
BLEDfu bledfu;
BLEUart bleuart;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
BLEService _mota_service = BLEService(mesh::ota::BLE_MOTA_SERVICE_UUID);
BLECharacteristic _mota_request =
BLECharacteristic(mesh::ota::BLE_MOTA_REQUEST_UUID);
BLECharacteristic _mota_response =
BLECharacteristic(mesh::ota::BLE_MOTA_RESPONSE_UUID);
mesh::ota::BleMotaStream _mota_stream;
#endif
bool _isEnabled;
bool _isDeviceConnected;
uint16_t _conn_handle;
@@ -56,6 +68,13 @@ class SerialBLEInterface : public BaseSerialInterface {
static void onPairingComplete(uint16_t connection_handle, uint8_t auth_status);
static void onBLEEvent(ble_evt_t* evt);
static void onBleUartRX(uint16_t conn_handle);
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
static void onMotaResponse(uint16_t conn_handle,
BLECharacteristic* characteristic,
uint8_t* data, uint16_t length);
static size_t sendMotaRequest(void* context, const uint8_t* data,
size_t length);
#endif
public:
SerialBLEInterface() {
@@ -91,6 +110,12 @@ public:
}
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
Stream& motaStream() { return _mota_stream; }
bool isMotaChannelReady();
bool isMotaStreamActive() const { return _mota_stream.isActive(); }
void setMotaStreamActive(bool active) { _mota_stream.setActive(active); }
#endif
};
#if BLE_DEBUG_LOGGING && ARDUINO
+2 -1
View File
@@ -11,6 +11,7 @@ bool FolderMotaStore::readByteT(uint8_t& b) const {
while ((millis() - t0) < _to) {
int c = _io.read();
if (c >= 0) { b = (uint8_t)c; return true; }
delay(1); // let BLE/WiFi callbacks deliver the response
}
return false;
}
@@ -43,7 +44,7 @@ bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen,
uint32_t t0 = millis(); bool got = false; uint8_t prev = 0;
while ((millis() - t0) < _to) { // scan for response magic 'm' 's' (tolerate noise)
int c = _io.read();
if (c < 0) continue;
if (c < 0) { delay(1); continue; }
if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; }
prev = (uint8_t)c;
}
+2 -1
View File
@@ -11,6 +11,7 @@ bool SerialMotaSource::readByteT(uint8_t& b) {
while ((millis() - t0) < _to) {
int c = _io.read();
if (c >= 0) { b = (uint8_t)c; return true; }
delay(1); // let BLE/WiFi callbacks deliver the response
}
return false;
}
@@ -43,7 +44,7 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
uint8_t prev = 0;
while ((millis() - t0) < _to) {
int c = _io.read();
if (c < 0) continue;
if (c < 0) { delay(1); continue; }
if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; }
prev = (uint8_t)c;
}
+10 -1
View File
@@ -321,8 +321,17 @@ struct OtaContext {
FOLDER_LINK_NONE = 0,
FOLDER_LINK_SERIAL,
FOLDER_LINK_TCP,
FOLDER_LINK_BLE,
};
FolderLink folderLink() const { return _folder_link; }
static const char* folderLinkName(FolderLink link) {
switch (link) {
case FOLDER_LINK_SERIAL: return "serial";
case FOLDER_LINK_TCP: return "tcp";
case FOLDER_LINK_BLE: return "ble";
default: return "none";
}
}
bool folderSourceStats(uint16_t& offered, uint16_t& advertised) const {
return manager.sourceStats(_folder_source, offered, advertised);
}
@@ -337,7 +346,7 @@ struct OtaContext {
}
if (folder_active && _folder_link != link) {
snprintf(msg, cap, "ERR folder already attached via %s",
_folder_link == FOLDER_LINK_TCP ? "tcp" : "serial");
folderLinkName(_folder_link));
return false;
}
if (folder_active && _folder_source == source) {
+5 -1
View File
@@ -447,6 +447,7 @@ public:
FetchError fetchError() const { return _fetch_error; }
uint32_t blocksHave() const { return _have; }
uint32_t blocksTotal() const { return _fbc; }
uint32_t packetsSent() const { return _packets_sent; }
uint8_t fetchPipelineWidth() const { return _pipeline_width; }
static constexpr uint8_t fetchPipelineCapacity() { return OTA_FETCH_PIPELINE; }
uint32_t fetchRetryTimeoutMs() const;
@@ -482,7 +483,9 @@ private:
bool expandCatalog();
bool emit(const uint8_t* b, uint16_t n, bool flood) {
return _send && n && _send(_ctx, b, n, flood);
const bool sent = _send && n && _send(_ctx, b, n, flood);
if (sent) _packets_sent++;
return sent;
}
void handleAdv(const uint8_t* m, uint16_t n); // beacon -> sources table (+ query if interested)
void handleQuery(const uint8_t* m, uint16_t n); // serve: reply OTA_HAVE catalog
@@ -655,6 +658,7 @@ private:
uint32_t _radio_packet_airtime_ms = 0; // measured for MAX_TRANS_UNIT at active SF/BW
uint16_t _tx_spacing_permille = 2000; // 1/duty-cycle; default airtime factor 1 => 50% TX
uint8_t _observed_path_transmissions = 0; // source + relays; 0 falls back to configured max_hops+1
uint32_t _packets_sent = 0; // OTA packets accepted by the radio adapter (wrap-safe)
// multi-fragment manifest reassembly (a signed v2 manifest exceeds one packet)
uint8_t _mf_buf[OTA_MF_MAXFRAG * OTA_MF_FRAG]; // sized to the fragment cap so no valid manifest is silently dropped
uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap)
+6
View File
@@ -15,6 +15,7 @@ class DisplayDriver {
int _w, _h;
protected:
DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; }
static size_t trimLastUTF8Codepoint(char* str, size_t length) {
if (length == 0) return 0;
@@ -30,6 +31,11 @@ public:
int height() const { return _h; }
virtual bool isOn() = 0;
virtual bool supportsRotation() const { return false; }
virtual bool setRotationDegrees(uint16_t degrees) {
(void)degrees;
return false;
}
virtual bool isEink() { return false; } // default to non-eink, override in eink drivers
virtual void forceFullRefresh() {} // next refresh will be full for eink
virtual void turnOn() = 0;
+28 -4
View File
@@ -22,10 +22,34 @@ bool SSD1306Display::begin() {
if (_peripher_power) _peripher_power->claim();
_isOn = true;
}
#ifdef DISPLAY_ROTATION
display.setRotation(DISPLAY_ROTATION);
#endif
return display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) && i2c_probe(Wire, DISPLAY_ADDRESS);
const bool started = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS,
true, false)
&& i2c_probe(Wire, DISPLAY_ADDRESS);
if (started) setRotationDegrees(0);
return started;
}
bool SSD1306Display::setRotationDegrees(uint16_t degrees) {
uint8_t rotation;
switch (degrees) {
case 0:
#ifdef DISPLAY_ROTATION
rotation = DISPLAY_ROTATION & 3;
#else
rotation = 0;
#endif
break;
case 90: rotation = 1; break;
case 180: rotation = 2; break;
case 270: rotation = 3; break;
default: return false;
}
display.setRotation(rotation);
setDimensions(display.width(), display.height());
display.clearDisplay();
display.display();
return true;
}
void SSD1306Display::turnOn() {
+2
View File
@@ -32,6 +32,8 @@ public:
bool begin();
bool isOn() override { return _isOn; }
bool supportsRotation() const override { return true; }
bool setRotationDegrees(uint16_t degrees) override;
void turnOn() override;
void turnOff() override;
void clear() override;
+1
View File
@@ -51,6 +51,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_companion_status_response` | `src/helpers/CompanionStatusResponse.h` | request-tag correlation and minimum status-response length, including rejection of the three-entry ACL payload that previously masqueraded as status |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition and passthrough ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing |
| `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_ble_mota_control` | `src/helpers/BleMotaStream.h`, `CompanionMotaControl.h` | encrypted mOTA channel ring buffering, overflow fail-closed behavior, request gating, and strict rejection of injected or USB-ownership control commands |
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
@@ -0,0 +1,149 @@
#include <gtest/gtest.h>
#include "helpers/BleMotaStream.h"
#include "helpers/CompanionMotaControl.h"
#include <cstring>
#include <vector>
namespace {
bool allowed(const char* text) {
return mesh::companion::isBleOtaControlCommandAllowed(
reinterpret_cast<const uint8_t*>(text), std::strlen(text));
}
TEST(BleMotaControl, AllowsOnlyBoundedOtaSessionCommands) {
EXPECT_TRUE(allowed("tempradio"));
EXPECT_TRUE(allowed("tempradio 915,250,5,5,10"));
EXPECT_TRUE(allowed("normalradio"));
EXPECT_TRUE(allowed("ota"));
EXPECT_TRUE(allowed("ota status"));
EXPECT_TRUE(allowed("ota neighbors"));
EXPECT_FALSE(allowed("tempradio "));
EXPECT_FALSE(allowed("normalradio now"));
EXPECT_FALSE(allowed("otafolder status"));
EXPECT_FALSE(allowed("ota folder on"));
EXPECT_FALSE(allowed("ota folder off"));
EXPECT_FALSE(allowed("reboot"));
}
TEST(BleMotaControl, RejectsCommandInjectionBytes) {
const uint8_t embedded_nul[] = {'o', 't', 'a', ' ', 's', 't', 0, 'a'};
const uint8_t newline[] = {'o', 't', 'a', ' ', 's', 't', '\n', 'x'};
const uint8_t carriage_return[] = {'n', 'o', 'r', 'm', 'a', 'l', 'r',
'a', 'd', 'i', 'o', '\r'};
const uint8_t shell_chain[] = {'o', 't', 'a', ' ', 's', 't', 'a', 't',
'u', 's', ';', 'r', 'e', 'b', 'o', 'o', 't'};
EXPECT_FALSE(mesh::companion::isBleOtaControlCommandAllowed(
embedded_nul, sizeof(embedded_nul)));
EXPECT_FALSE(mesh::companion::isBleOtaControlCommandAllowed(
newline, sizeof(newline)));
EXPECT_FALSE(mesh::companion::isBleOtaControlCommandAllowed(
carriage_return, sizeof(carriage_return)));
EXPECT_FALSE(mesh::companion::isBleOtaControlCommandAllowed(
shell_chain, sizeof(shell_chain)));
}
struct SendCapture {
std::vector<uint8_t> bytes;
};
size_t captureSend(void* context, const uint8_t* data, size_t length) {
auto* capture = static_cast<SendCapture*>(context);
capture->bytes.assign(data, data + length);
return length;
}
TEST(BleMotaStream, IsInactiveAndEmptyByDefault) {
mesh::ota::BleMotaStream stream;
const uint8_t byte = 7;
EXPECT_FALSE(stream.isActive());
EXPECT_FALSE(stream.pushRx(&byte, 1));
EXPECT_EQ(stream.available(), 0);
EXPECT_EQ(stream.read(), -1);
EXPECT_EQ(stream.write(&byte, 1), 0u);
}
TEST(BleMotaStream, CarriesFragmentedResponseAcrossRingWrap) {
mesh::ota::BleMotaStream stream;
stream.setActive(true);
std::vector<uint8_t> first(200);
for (size_t i = 0; i < first.size(); ++i) first[i] = i;
ASSERT_TRUE(stream.pushRx(first.data(), first.size()));
for (size_t i = 0; i < 190; ++i) {
ASSERT_EQ(stream.read(), first[i]);
}
std::vector<uint8_t> second(100);
for (size_t i = 0; i < second.size(); ++i) second[i] = 200 + i;
ASSERT_TRUE(stream.pushRx(second.data(), second.size()));
EXPECT_EQ(stream.available(), 110);
for (size_t i = 190; i < first.size(); ++i) {
ASSERT_EQ(stream.read(), first[i]);
}
for (uint8_t value : second) ASSERT_EQ(stream.read(), value);
EXPECT_EQ(stream.read(), -1);
}
TEST(BleMotaStream, RejectsOverflowWithoutQueuingPartialFragment) {
mesh::ota::BleMotaStream stream;
stream.setActive(true);
std::vector<uint8_t> first(240, 0x11);
std::vector<uint8_t> overflow(20, 0x22);
ASSERT_TRUE(stream.pushRx(first.data(), first.size()));
EXPECT_FALSE(stream.pushRx(overflow.data(), overflow.size()));
EXPECT_TRUE(stream.overflowed());
EXPECT_EQ(stream.available(), 240);
while (stream.available()) EXPECT_EQ(stream.read(), 0x11);
}
TEST(BleMotaStream, SendsRequestsOnlyWhileActiveAndClearsOnStop) {
mesh::ota::BleMotaStream stream;
SendCapture capture;
stream.setSender(captureSend, &capture);
stream.setActive(true);
const uint8_t request[] = {'M', 'S', 1, 1};
EXPECT_EQ(stream.write(request, sizeof(request)), sizeof(request));
EXPECT_EQ(capture.bytes,
std::vector<uint8_t>(request, request + sizeof(request)));
const uint8_t response[] = {'m', 's', 1, 0, 1, 1};
ASSERT_TRUE(stream.pushRx(response, sizeof(response)));
stream.setActive(false);
EXPECT_EQ(stream.available(), 0);
EXPECT_EQ(stream.write(request, sizeof(request)), 0u);
}
TEST(BleMotaStream, DisconnectAndRestartDiscardEveryPartialOldResponse) {
mesh::ota::BleMotaStream stream;
stream.setActive(true);
const uint8_t partial_old_response[] = {'m', 's', 3, 0, 0xAA, 0xBB};
ASSERT_TRUE(stream.pushRx(partial_old_response,
sizeof(partial_old_response)));
ASSERT_EQ(stream.available(), (int)sizeof(partial_old_response));
// A BLE loss can happen between response fragments. Starting the next
// source session must never splice those stale bytes into its first frame.
stream.setActive(false);
EXPECT_EQ(stream.available(), 0);
stream.setActive(true);
EXPECT_EQ(stream.available(), 0);
const uint8_t fresh_response[] = {'m', 's', 1, 0, 1, 1};
ASSERT_TRUE(stream.pushRx(fresh_response, sizeof(fresh_response)));
for (uint8_t expected : fresh_response) {
EXPECT_EQ(stream.read(), expected);
}
EXPECT_EQ(stream.read(), -1);
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+181
View File
@@ -9,6 +9,9 @@ fail() {
exit 1
}
[ "$OPTION3_BUILD_WORKERS" -eq 1 ] \
|| fail "logging matrix permits concurrent PlatformIO target builds"
# Full targets are synthesized from an ordinary transport environment. Check
# the resolved PlatformIO configuration so board-specific display, GPS, input,
# and BLE constraints cannot silently disappear through that inheritance.
@@ -53,6 +56,45 @@ require(m8_usb, "lib_deps", "GxEPD2 @ 1.6.9")
reject(m8_usb, "lib_deps", "GxEPD2 @ 1.6.2")
require("ThinkNode_M5_companion_radio_wifi", "build_flags", "UI_RECENT_LIST_SIZE=9")
require("ThinkNode_M5_companion_radio_full", "build_flags", "SERIAL_TX=43")
require("ThinkNode_M2_companion_radio_full", "build_flags", "SERIAL_RX=44")
require("Xiao_S3_WIO_companion_radio_full", "build_flags", "SERIAL_TX=D6")
require("ThinkNode_M7_companion_radio_full", "build_flags", "ETHERNET_USE_CH390")
require("ThinkNode_M7_companion_radio_full", "build_src_filter", "helpers/ethernet/ch390")
require("ThinkNode_M7_companion_radio_full", "lib_deps", "ESP32-CH390")
require("RAK_4631_companion_radio_full", "build_flags", "ETHERNET_USE_RAK13800")
require("RAK_4631_companion_radio_full", "build_src_filter", "helpers/ethernet/RAK13800")
require("RAK_4631_companion_radio_full", "lib_deps", "RAK13800-W5100S")
require("Heltec_E290_companion_usb_ble", "build_flags", "ENABLE_USB_INTERFACE")
require("Heltec_E290_companion_usb_ble", "build_flags", "BLE_PIN_CODE=123456")
require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "ENABLE_USB_INTERFACE")
require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "BLE_PIN_CODE=123456")
for env_name in (
"Heltec_t114_without_display_repeater",
"Heltec_t114_repeater",
"Heltec_t114_repeater_lora_ota_no_external_sensors",
"RAK_3112_repeater",
"RAK_11310_repeater",
"ProMicro_repeater",
"waveshare_rp2040_lora_repeater",
"solarxiao_30S_repeater",
"solarxiao_33S_repeater",
"Heltec_v3_repeater",
"Heltec_WSL3_repeater",
"Heltec_t096_repeater",
"Heltec_t096_repeater_lora_ota_no_external_sensors",
"RAK_4631_repeater",
"RAK_4631_repeater_lora_ota_no_external_sensors",
"LilyGo_TLora_V2_1_1_6_repeater",
):
require(env_name, "build_flags", "WITH_RS232_BRIDGE=")
require(env_name, "build_flags", "RS232_BRIDGE_MERGED=1")
require(env_name, "build_src_filter", "helpers/bridges/RS232Bridge.cpp")
require("RAK_4631_repeater", "build_flags", "WITH_RS232_BRIDGE_ALT=Serial1")
require("RAK_4631_repeater", "build_flags", "WITH_RS232_BRIDGE_UART=2")
reject("wio-e5_repeater", "build_flags", "WITH_RS232_BRIDGE=")
for env_name in (
"t1000e_companion_radio_usb",
@@ -133,6 +175,63 @@ configure_effective_build_profile build-firmware >/dev/null
[ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = 0 ] \
|| fail "non-repeater nRF52 target incorrectly scheduled two artifacts"
# Ordinary USB-loggable roles compile their historical logging profile into
# the canonical artifact. OTA receivers, KISS, and BLE keep their distinct
# stream/partition contracts. A standard ESP32 artifact still embeds logging
# when the same target also has an expanded FULL profile.
PIO_ENV_PLATFORM_BY_NAME[wio-e5-mini_repeater]=STM32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[nrf_kiss_modem]=NRF52_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[nrf_companion_radio_ble]=NRF52_PLATFORM
uses_merged_standard_usb_logging nrf_sensor \
|| fail "ordinary nRF52 sensor omitted merged USB logging"
uses_merged_standard_usb_logging wio-e5-mini_repeater \
|| fail "size-constrained STM32 target omitted merged packet logging"
if uses_merged_standard_usb_logging nrf_repeater_lora_ota_no_external_sensors; then
fail "LoRa OTA receiver incorrectly merged USB logging"
fi
if uses_merged_standard_usb_logging nrf_kiss_modem; then
fail "KISS target incorrectly merged plaintext USB logging"
fi
if uses_merged_standard_usb_logging nrf_companion_radio_ble; then
fail "BLE Companion incorrectly merged USB logging"
fi
uses_merged_standard_usb_logging esp_repeater \
|| fail "standard ESP32 target omitted logging because FULL also exists"
PLATFORMIO_BUILD_FLAGS=""
MESHDEBUG_OVERRIDE=""
PACKET_LOGGING_OVERRIDE=""
DISABLE_DEBUG=0
apply_merged_standard_usb_logging_profile nrf_sensor
[[ "$PLATFORMIO_BUILD_FLAGS" == *-DMESH_PACKET_LOGGING=1* ]] \
|| fail "merged profile omitted packet logging"
[[ "$PLATFORMIO_BUILD_FLAGS" == *-DMESH_DEBUG=1* ]] \
|| fail "merged profile omitted mesh diagnostics"
PLATFORMIO_BUILD_FLAGS=""
apply_merged_standard_usb_logging_profile wio-e5-mini_repeater
[[ "$PLATFORMIO_BUILD_FLAGS" == *-DMESH_PACKET_LOGGING=1* ]] \
|| fail "constrained merged profile omitted packet logging"
[[ "$PLATFORMIO_BUILD_FLAGS" != *-DMESH_DEBUG=1* ]] \
|| fail "constrained merged profile enabled oversized mesh diagnostics"
if declare -f run_logging_matrix_build_targets \
| grep -q 'FIRMWARE_FILENAME_INFIX="logging"'; then
fail "logging matrix still emits a separate standard logging artifact"
fi
if declare -f get_firmware_filename | grep -q 'filename_infix="logging"'; then
fail "explicit packet logging still renames the ordinary artifact"
fi
FIRMWARE_FILENAME_INFIX=""
ESP32_FULL_BUILD=0
PACKET_LOGGING_OVERRIDE="on"
MQTT_BRIDGE_OVERRIDE="off"
[ "$(get_firmware_filename nrf_sensor vtest)" = "nrf_sensor-vtest" ] \
|| fail "packet logging still creates a separately named artifact"
PACKET_LOGGING_OVERRIDE=""
MQTT_BRIDGE_OVERRIDE=""
if declare -f run_logging_matrix_build_targets | grep -q 'Profile 2'; then
fail "logging matrix still labels a separate Profile 2"
fi
calls=()
build_firmware() {
calls+=("$1:$BUILD_PROFILE_EFFECTIVE:$SKIP_DECLARED_REDUCTIONS:$FIRMWARE_OUTPUT_ENV_NAME")
@@ -220,6 +319,7 @@ SUPPORTED_PIO_ENVS=(
Station_G2_companion_radio_ble
Station_G2_companion_radio_wifi
Station_G2_companion_radio_full
Station_G2_terminal_chat
)
for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do
PIO_ENV_PLATFORM_BY_NAME[$env_name]=ESP32_PLATFORM
@@ -233,6 +333,87 @@ mapfile -t companion_release_targets < <(
[ "${companion_release_targets[0]}" = Station_G2_companion_radio_full ] \
|| fail "Station G2 release selected a non-Full Companion artifact"
# Full Companion's text terminal also replaces a separate Terminal Chat image
# in bulk builds. Cover direct ESP32/nRF52 names and the canonical Heltec names
# whose hardware/revision suffixes differ from their Terminal Chat targets.
PIO_ENV_PLATFORM_BY_NAME[RAK_3401_terminal_chat]=NRF52_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[RAK_3401_companion_radio_full]=NRF52_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_v4_terminal_chat]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_v4_2_v4_3_companion_radio_full_femon]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_v4_tft_terminal_chat]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_v4_tft_companion_radio_full_femon]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_tracker_v2_terminal_chat]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[heltec_tracker_v2_companion_radio_full_femon]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[PicoW_terminal_chat]=RP2040_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[PicoW_companion_radio_usb]=RP2040_PLATFORM
PIO_ENV_BOARD_BY_NAME[PicoW_terminal_chat]=rpipicow
PIO_ENV_BOARD_BY_NAME[PicoW_companion_radio_usb]=rpipicow
[ "$(get_terminal_chat_full_companion_replacement RAK_3401_terminal_chat)" \
= RAK_3401_companion_radio_full ] \
|| fail "nRF52 Terminal Chat did not map to Full Companion"
[ "$(get_terminal_chat_full_companion_replacement heltec_v4_terminal_chat)" \
= heltec_v4_2_v4_3_companion_radio_full_femon ] \
|| fail "Heltec V4 Terminal Chat did not map to its canonical Full Companion"
[ "$(get_terminal_chat_full_companion_replacement heltec_v4_tft_terminal_chat)" \
= heltec_v4_tft_companion_radio_full_femon ] \
|| fail "Heltec V4 TFT Terminal Chat did not map to Full Companion"
[ "$(get_terminal_chat_full_companion_replacement heltec_tracker_v2_terminal_chat)" \
= heltec_tracker_v2_companion_radio_full_femon ] \
|| fail "Heltec Tracker V2 Terminal Chat did not map to Full Companion"
is_redundant_bulk_build_target Station_G2_terminal_chat \
|| fail "ESP32 Terminal Chat remained in bulk builds beside Full Companion"
is_redundant_bulk_build_target RAK_3401_terminal_chat \
|| fail "nRF52 Terminal Chat remained in bulk builds beside Full Companion"
[ "$(get_terminal_chat_companion_replacement PicoW_terminal_chat)" \
= PicoW_companion_radio_usb ] \
|| fail "RP2040 Terminal Chat did not map to USB Companion"
is_redundant_bulk_build_target PicoW_terminal_chat \
|| fail "Terminal Chat remained beside its matching USB Companion"
PIO_ENV_PLATFORM_BY_NAME[RAK_4631_companion_radio_ethernet]=NRF52_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[RAK_4631_companion_radio_full]=NRF52_PLATFORM
[ "$(get_nrf52_full_companion_replacement \
RAK_4631_companion_radio_ethernet)" = RAK_4631_companion_radio_full ] \
|| fail "RAK4631 Ethernet Companion did not map to Full Companion"
PIO_ENV_PLATFORM_BY_NAME[ThinkNode_M2_companion_radio_serial]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[ThinkNode_M2_companion_radio_full]=ESP32_PLATFORM
[ "$(get_esp32_full_companion_replacement \
ThinkNode_M2_companion_radio_serial)" = ThinkNode_M2_companion_radio_full ] \
|| fail "serial Companion did not map to Full Companion"
[ "$(get_combined_usb_ble_companion_replacement \
Heltec_E290_companion_usb)" = Heltec_E290_companion_usb_ble ] \
|| fail "E290 USB Companion did not map to USB+BLE Companion"
[ "$(get_combined_usb_ble_companion_replacement \
Heltec_T190_companion_radio_ble_)" \
= Heltec_T190_companion_radio_usb_ble_ ] \
|| fail "T190 BLE Companion did not map to USB+BLE Companion"
if is_redundant_bulk_build_target RAK_4631_repeater_ethernet \
|| is_redundant_bulk_build_target RAK_4631_room_server_ethernet; then
fail "RAK4631 repeater/room Ethernet artifact was incorrectly merged"
fi
[ "$(get_merged_rs232_repeater_replacement \
RAK_4631_repeater_bridge_rs232_serial1)" = RAK_4631_repeater ] \
|| fail "RAK4631 Serial1 bridge did not map to merged repeater"
[ "$(get_merged_rs232_repeater_replacement \
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors)" \
= RAK_4631_repeater_lora_ota_no_external_sensors ] \
|| fail "RAK4631 Serial2 OTA bridge did not map to merged repeater"
[ "$(get_merged_rs232_repeater_replacement \
Heltec_t114_repeater_bridge_rs232)" = Heltec_t114_repeater ] \
|| fail "T114 RS232 bridge did not map to merged repeater"
[ "$(get_merged_rs232_repeater_replacement \
Heltec_t096_repeater_bridge_rs232)" = Heltec_t096_repeater ] \
|| fail "T096 RS232 bridge did not map to merged repeater"
is_redundant_bulk_build_target Heltec_v3_repeater_bridge_rs232 \
|| fail "merged RS232 bridge remained in canonical bulk builds"
if is_redundant_bulk_build_target wio-e5-repeater_bridge_rs232; then
fail "capacity-constrained Wio-E5 RS232 bridge was incorrectly merged"
fi
# A WiFi base's final -UENABLE_OTA must not win over the Full Companion's
# source-only OTA overlay. That mismatch compiles out both the TCP terminal and
# seeder while still leaving a superficially valid binary.
@@ -414,6 +414,70 @@ TEST(CLICommandUtils, RejectsInvalidOrOverflowingIntegers) {
EXPECT_EQ(123, value);
}
TEST(CLICommandUtils, ParsesRadioTuplesWithoutFloatScanf) {
float frequency = 0.0f;
float bandwidth = 0.0f;
uint8_t spreading_factor = 0;
uint8_t coding_rate = 0;
uint32_t timeout_minutes = 0;
EXPECT_TRUE(mesh::cli::parseRadioTupleStrict(
"910.525,62.5,7,5", frequency, bandwidth,
spreading_factor, coding_rate));
EXPECT_FLOAT_EQ(910.525f, frequency);
EXPECT_FLOAT_EQ(62.5f, bandwidth);
EXPECT_EQ(7, spreading_factor);
EXPECT_EQ(5, coding_rate);
EXPECT_TRUE(mesh::cli::parseTemporaryRadioTupleStrict(
" 909.950 , 250 , 5 , 5 , 120 ", frequency, bandwidth,
spreading_factor, coding_rate, timeout_minutes));
EXPECT_FLOAT_EQ(909.950f, frequency);
EXPECT_FLOAT_EQ(250.0f, bandwidth);
EXPECT_EQ(5, spreading_factor);
EXPECT_EQ(5, coding_rate);
EXPECT_EQ(120u, timeout_minutes);
}
TEST(CLICommandUtils, RejectsMalformedRadioTuples) {
float frequency = 123.0f;
float bandwidth = 456.0f;
uint8_t spreading_factor = 7;
uint8_t coding_rate = 5;
uint32_t timeout_minutes = 60;
EXPECT_FALSE(mesh::cli::parseRadioTupleStrict(
nullptr, frequency, bandwidth, spreading_factor, coding_rate));
EXPECT_FALSE(mesh::cli::parseRadioTupleStrict(
"910.525,62.5,7", frequency, bandwidth,
spreading_factor, coding_rate));
EXPECT_FALSE(mesh::cli::parseRadioTupleStrict(
"910.525,62.5,7,5,1", frequency, bandwidth,
spreading_factor, coding_rate));
EXPECT_FALSE(mesh::cli::parseRadioTupleStrict(
"910.525x,62.5,7,5", frequency, bandwidth,
spreading_factor, coding_rate));
EXPECT_FALSE(mesh::cli::parseRadioTupleStrict(
"910.525,62.5,256,5", frequency, bandwidth,
spreading_factor, coding_rate));
EXPECT_FALSE(mesh::cli::parseTemporaryRadioTupleStrict(
"909.950,250,5,5,1,99", frequency, bandwidth,
spreading_factor, coding_rate, timeout_minutes));
EXPECT_FALSE(mesh::cli::parseTemporaryRadioTupleStrict(
"909.950,250,5,5,1x", frequency, bandwidth,
spreading_factor, coding_rate, timeout_minutes));
EXPECT_FALSE(mesh::cli::parseTemporaryRadioTupleStrict(
"909.950,250,5,5,2147483648", frequency, bandwidth,
spreading_factor, coding_rate, timeout_minutes));
EXPECT_FLOAT_EQ(123.0f, frequency);
EXPECT_FLOAT_EQ(456.0f, bandwidth);
EXPECT_EQ(7, spreading_factor);
EXPECT_EQ(5, coding_rate);
EXPECT_EQ(60u, timeout_minutes);
}
TEST(RadioPowerLimits, LeavesUnspecifiedBackendRangeToDriver) {
EXPECT_EQ(INT8_MIN, mesh::minLoRaTxPowerForFrequency(915.0f));
EXPECT_EQ(INT8_MAX, mesh::maxLoRaTxPowerForFrequency(915.0f));
@@ -217,6 +217,7 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
saved.radio_fem_rxgain = 0;
saved.radio_fem_txgain = 1;
saved.usb_logging_enabled = 0;
saved.bridge_uart = 2;
MockPrintStream output;
ASSERT_TRUE(saved.saveSerial(output));
@@ -225,17 +226,20 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
EXPECT_NE(std::string::npos, serialised.find("fem_rxgain:0"));
EXPECT_NE(std::string::npos, serialised.find("fem_txgain:1"));
EXPECT_NE(std::string::npos, serialised.find("usb_log:0"));
EXPECT_NE(std::string::npos, serialised.find("uart:2"));
MockInputStream input(serialised.c_str());
NodePrefs loaded;
loaded.radio_fem_rxgain = 1;
loaded.radio_fem_txgain = 0;
loaded.usb_logging_enabled = 1;
loaded.bridge_uart = 1;
ASSERT_TRUE(loaded.loadSerial(input));
EXPECT_EQ(0, loaded.radio_fem_rxgain);
EXPECT_EQ(1, loaded.radio_fem_txgain);
EXPECT_EQ(0, loaded.usb_logging_enabled);
EXPECT_EQ(2, loaded.bridge_uart);
}
+125 -5
View File
@@ -158,6 +158,31 @@ assert(catalog.rows.some(function (item) {
return item.target === "RAK_4631_companion_radio_usb-logging";
}));
const rakSensor = profile("RAK_4631_sensor");
assert.strictEqual(rakSensor.logging, "usb-runtime");
assert.deepStrictEqual(rakSensor.loggingModes, ["none", "usb"]);
const standardBesideFull = picker.applyMergedStandardUsbLoggingCapabilities([
Object.assign(picker.parseTargetProfile("Example_repeater"), {
hardware: "Example",
role: "repeater",
variant: "default",
feature: "standard",
logging: "none",
ota: "none",
}),
Object.assign(picker.parseTargetProfile("Example_repeater-full-logging-ota"), {
hardware: "Example",
role: "repeater",
variant: "default",
feature: "full",
logging: "usb",
ota: "lora-receiver",
}),
]);
assert.strictEqual(standardBesideFull[0].logging, "usb-runtime");
assert.deepStrictEqual(standardBesideFull[0].loggingModes, ["none", "usb"]);
const v4Full = profile("heltec_v4_2_v4_3_companion_radio_full_femon");
assert.strictEqual(v4Full.logging, "usb-runtime");
assert.deepStrictEqual(v4Full.loggingModes, ["none", "usb"]);
@@ -192,6 +217,58 @@ assert.strictEqual(v4Full.dedicatedUsbLogging, true);
}
);
const combinedUsbBle = [
picker.parseTargetProfile("Heltec_E290_companion_usb"),
picker.parseTargetProfile("Heltec_E290_companion_ble"),
picker.parseTargetProfile("Heltec_E290_companion_usb_ble"),
];
combinedUsbBle.forEach(function (item) {
item.installKinds = ["bin"];
});
assert.strictEqual(combinedUsbBle[2].mode, "usb-ble");
assert.strictEqual(combinedUsbBle[2].variant, "default");
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(combinedUsbBle).map(function (item) {
return item.target;
}),
["Heltec_E290_companion_usb_ble"]
);
const fullWithWiredTransports = [
"ThinkNode_M7_companion_radio_full",
"ThinkNode_M7_companion_radio_usb",
"ThinkNode_M7_companion_radio_ble",
"ThinkNode_M7_companion_radio_wifi",
"ThinkNode_M7_companion_radio_serial",
"ThinkNode_M7_companion_radio_ethernet",
"ThinkNode_M7_terminal_chat",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["bin"],
});
});
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(fullWithWiredTransports).map(
function (item) { return item.target; }
),
["ThinkNode_M7_companion_radio_full"]
);
const terminalWithUsb = [
"PicoW_terminal_chat",
"PicoW_companion_radio_usb",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["uf2"],
});
});
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(terminalWithUsb).map(
function (item) { return item.target; }
),
["PicoW_companion_radio_usb"]
);
const companionBle = picker.parseTargetProfile(
"Heltec_t096_companion_radio_ble_ps_femon"
);
@@ -323,10 +400,23 @@ assert.deepStrictEqual(
const standardRepeater = profile("Station_G2_repeater");
assert.strictEqual(standardRepeater.hardwareFamily, "Station_G2");
assert.strictEqual(standardRepeater.role, "repeater");
assert.strictEqual(standardRepeater.logging, "none");
assert.strictEqual(standardRepeater.logging, "usb-runtime");
assert.deepStrictEqual(standardRepeater.loggingModes, ["none", "usb"]);
assert.strictEqual(standardRepeater.ota, "none");
assert.strictEqual(standardRepeater.mode, "standard");
assert.strictEqual(standardRepeater.feature, "standard");
const mergedLoggingProfiles = picker.applyMergedStandardUsbLoggingCapabilities([
Object.assign(picker.parseTargetProfile("PicoW_room_server"), {
ota: "none",
}),
Object.assign(picker.parseTargetProfile("PicoW_kiss_modem"), {
ota: "none",
}),
]);
assert.strictEqual(mergedLoggingProfiles[0].logging, "usb-runtime");
assert.deepStrictEqual(mergedLoggingProfiles[0].loggingModes, ["none", "usb"]);
assert.strictEqual(mergedLoggingProfiles[1].logging, "none");
assert.strictEqual(
picker.canonicalAsset(standardRepeater.files, "merged-bin").name,
"Station_G2_repeater-" + family + "-merged.bin"
@@ -390,6 +480,34 @@ assert.strictEqual(wio.hardware, "wio-e5");
assert.strictEqual(wio.role, "repeater");
assert.strictEqual(wio.mode, "rs232");
const mergedRs232 = [
"RAK_4631_repeater",
"RAK_4631_repeater_bridge_rs232_serial1",
"RAK_4631_repeater_bridge_rs232_serial2",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["uf2"],
});
});
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(mergedRs232).map(function (item) {
return item.target;
}),
["RAK_4631_repeater"]
);
const constrainedRs232 = [
"wio-e5_repeater",
"wio-e5-repeater_bridge_rs232",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["hex"],
});
});
assert.strictEqual(
picker.omitTransportsReplacedByFull(constrainedRs232).length,
2
);
const matches = catalog.profiles.filter(function (item) {
return picker.profileMatches(item, {
hardware: "Station_G2",
@@ -397,10 +515,12 @@ const matches = catalog.profiles.filter(function (item) {
logging: "usb",
});
});
assert.strictEqual(matches.length, 1);
assert.strictEqual(
matches[0].target,
"Station_G2_repeater_observer_mqtt-full-usb-wifi"
assert.deepStrictEqual(
matches.map(function (item) { return item.target; }).sort(),
[
"Station_G2_repeater",
"Station_G2_repeater_observer_mqtt-full-usb-wifi",
]
);
assert.deepStrictEqual(
+18
View File
@@ -1396,6 +1396,24 @@ static bool gated_capture_send(void* ctx, const uint8_t* msg, uint16_t len, bool
capture->items.emplace_back(msg, msg + len);
return true;
}
TEST(OtaMetrics, CountsOnlyPacketsAcceptedByTheRadioAdapter) {
OtaManager manager;
GatedCapture sent;
manager.begin(0, gated_capture_send, &sent);
EXPECT_EQ(manager.packetsSent(), 0u);
manager.announce();
EXPECT_EQ(manager.packetsSent(), 0u);
EXPECT_TRUE(sent.items.empty());
sent.accept = true;
manager.announce();
manager.announce();
EXPECT_EQ(manager.packetsSent(), 2u);
EXPECT_EQ(sent.items.size(), 2u);
}
// Drive the bus to quiescence: deliver queued messages; when idle, advance the client's clock (monotonic
// across calls, so a jittered query scheduled in a prior pump still comes due) and call loop() (fires the
// scheduled catalog query / block re-requests). Two idle ticks in a row = quiescent.
+571
View File
@@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""Reference BLE controller/seeder for an nRF52 Full Companion.
This is primarily for Linux testing (including a Raspberry Pi Zero). A phone
app can implement the same documented GATT and Companion frames.
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import shutil
import signal
import struct
import subprocess
import sys
from pathlib import Path
BLEAK_IMPORT_ERROR: ImportError | None = None
try:
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
except ImportError as exc: # pragma: no cover - depends on the test host
BLEAK_IMPORT_ERROR = exc
BleakClient = None # type: ignore[assignment,misc]
BleakScanner = None # type: ignore[assignment,misc]
class BleakError(Exception):
pass
NUS_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # host -> Companion
NUS_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # Companion -> host
MOTA_REQUEST_UUID = "2bfaa1ee-7030-459a-b65a-e7cfd5b09735"
MOTA_RESPONSE_UUID = "acf38a51-dd58-4dce-917f-0b1135e41b1a"
CMD_EXEC_LOCAL_OTA_CONTROL = 0x4A
CMD_BLE_MOTA_SOURCE = 0x4B
MOTA_ACTION_STATUS = 0
MOTA_ACTION_START = 1
MOTA_ACTION_STOP = 2
RESP_OK = 0
RESP_ERR = 1
MOTA_FLAG_CHANNEL_READY = 0x01
MOTA_FLAG_ATTACHED = 0x02
MOTA_FLAG_ANOTHER_LINK_ACTIVE = 0x04
OP_COUNT = 0x01
OP_DESCRIBE = 0x02
OP_READ = 0x03
STATUS_OK = 0
STATUS_ERR = 1
MOTA_READ_MAX = 192
MOTA_DESC_WIRE = 38
MOTA_HEADER_LEN = 8
MOTA_MANIFEST_LEN = 197
MOTA_TRAILER = b"vk496"
def xor_bytes(data: bytes, seed: int = 0) -> int:
result = seed
for value in data:
result ^= value
return result
@dataclasses.dataclass(frozen=True)
class MotaFile:
path: Path
size: int
descriptor: bytes
@staticmethod
def load(path: Path) -> "MotaFile":
size = path.stat().st_size
if size > 0xFFFFFFFF:
raise ValueError("container is too large for the mOTA protocol")
if size < MOTA_HEADER_LEN + MOTA_MANIFEST_LEN + len(MOTA_TRAILER):
raise ValueError("container is too short")
with path.open("rb") as stream:
header = stream.read(MOTA_HEADER_LEN)
manifest = stream.read(MOTA_MANIFEST_LEN)
stream.seek(-len(MOTA_TRAILER), 2)
trailer = stream.read(len(MOTA_TRAILER))
if header[:4] != b"mOTA":
raise ValueError("bad mOTA magic")
if struct.unpack_from("<I", header, 4)[0] != size:
raise ValueError("declared size does not match file size")
if trailer != MOTA_TRAILER:
raise ValueError("bad mOTA trailer")
flags = manifest[1]
target_id = struct.unpack_from("<I", manifest, 3)[0]
firmware_version = struct.unpack_from("<I", manifest, 7)[0]
payload_size = struct.unpack_from("<I", manifest, 15)[0]
block_size_log2 = manifest[19]
if not 1 <= block_size_log2 <= 24 or payload_size == 0:
raise ValueError("bad block geometry")
block_size = 1 << block_size_log2
block_count = (payload_size + block_size - 1) // block_size
leaves_offset = MOTA_HEADER_LEN + MOTA_MANIFEST_LEN
payload_offset = leaves_offset + block_count * 4
if payload_offset + payload_size + len(MOTA_TRAILER) != size:
raise ValueError("container geometry does not match file size")
descriptor = bytearray(MOTA_DESC_WIRE)
descriptor[0:4] = manifest[20:24]
struct.pack_into("<I", descriptor, 4, target_id)
struct.pack_into("<I", descriptor, 8, firmware_version)
descriptor[12] = manifest[56]
descriptor[13] = flags
struct.pack_into("<I", descriptor, 14, size)
struct.pack_into("<I", descriptor, 18, leaves_offset)
struct.pack_into("<I", descriptor, 22, block_count)
struct.pack_into("<I", descriptor, 26, payload_offset)
struct.pack_into("<I", descriptor, 30, payload_size)
descriptor[34] = block_size_log2
return MotaFile(path=path, size=size, descriptor=bytes(descriptor))
class Catalog:
def __init__(self, files: list[MotaFile], verbose: bool) -> None:
self.files = files
self.verbose = verbose
@staticmethod
def scan(directory: Path, recursive: bool, motatool: str,
verbose: bool) -> "Catalog":
pattern = "**/*.mota" if recursive else "*.mota"
paths = sorted(path for path in directory.glob(pattern) if path.is_file())
if not paths:
raise ValueError(f"no .mota files found under {directory}")
if len(paths) > 255:
raise ValueError("the Bluetooth mOTA catalog is limited to 255 files")
executable = shutil.which(motatool)
if executable is None:
raise ValueError(
f"{motatool!r} was not found; install motatool before serving"
)
files: list[MotaFile] = []
for path in paths:
verified = subprocess.run(
[executable, "verify", str(path)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
if verified.returncode != 0:
raise ValueError(
f"motatool verification failed for {path}:\n"
+ verified.stdout
)
try:
files.append(MotaFile.load(path))
except (OSError, ValueError) as exc:
raise ValueError(f"cannot serve {path}: {exc}") from exc
return Catalog(files, verbose)
def handle_request(self, frame: bytes) -> bytes | None:
if len(frame) < 4 or frame[:2] != b"MS":
return None
op = frame[2]
args = frame[3:-1]
if frame[-1] != xor_bytes(args, op):
return None
status = STATUS_ERR
payload = b""
if op == OP_COUNT and not args:
status = STATUS_OK
payload = bytes([len(self.files)])
elif op == OP_DESCRIBE and len(args) == 1:
index = args[0]
if index < len(self.files):
status = STATUS_OK
payload = self.files[index].descriptor
elif op == OP_READ and len(args) == 7:
index = args[0]
offset = struct.unpack_from("<I", args, 1)[0]
length = struct.unpack_from("<H", args, 5)[0]
if index < len(self.files) and length <= MOTA_READ_MAX:
mota = self.files[index]
end = offset + length
if end >= offset and end <= mota.size:
try:
if mota.path.stat().st_size == mota.size:
with mota.path.open("rb") as stream:
stream.seek(offset)
payload = stream.read(length)
if len(payload) == length:
status = STATUS_OK
except OSError:
payload = b""
response = bytearray(b"ms")
response.extend((op, status))
response.extend(payload)
response.append(xor_bytes(response))
if self.verbose:
if op == OP_COUNT:
detail = f"count={len(self.files)}"
elif op == OP_DESCRIBE and args:
detail = f"index={args[0]}"
elif op == OP_READ and len(args) == 7:
detail = f"index={args[0]} offset={struct.unpack_from('<I', args, 1)[0]}"
else:
detail = "invalid"
print(f"mOTA op=0x{op:02x} {detail} status={status}")
return bytes(response)
class BleSession:
def __init__(self, client: BleakClient, catalog: Catalog) -> None:
self.client = client
self.catalog = catalog
self.mota_requests: asyncio.Queue[bytes] = asyncio.Queue()
self.companion_chunks: asyncio.Queue[bytes] = asyncio.Queue()
self.mota_response_chunk_size = 20
async def negotiate_mtu(self) -> None:
"""Use the negotiated ATT payload when the backend can expose it.
BlueZ otherwise reports Bleak's conservative 23-byte default and each
192-byte mOTA read takes ten acknowledged writes. Its backend provides
the acquisition hook referenced by Bleak's own warning. Other backends
already expose ``mtu_size`` directly. Failure is only a performance
issue, so retain the universally safe 20-byte ATT payload fallback.
"""
mtu_size = 23
backend = getattr(self.client, "_backend", None)
acquire_mtu = getattr(backend, "_acquire_mtu", None)
if callable(acquire_mtu):
try:
await acquire_mtu()
candidate = getattr(backend, "_mtu_size", None)
if isinstance(candidate, int) and candidate >= 23:
mtu_size = candidate
except Exception:
pass
else:
try:
candidate = self.client.mtu_size
if isinstance(candidate, int) and candidate >= 23:
mtu_size = candidate
except Exception:
pass
self.mota_response_chunk_size = min(
MOTA_READ_MAX + 5, mtu_size - 3
)
if self.catalog.verbose:
print(
f"BLE MTU {mtu_size}; mOTA response chunks "
f"{self.mota_response_chunk_size} bytes"
)
def on_mota_request(self, _characteristic: object, data: bytearray) -> None:
self.mota_requests.put_nowait(bytes(data))
def on_companion_data(self, _characteristic: object,
data: bytearray) -> None:
self.companion_chunks.put_nowait(bytes(data))
async def serve(self, stop: asyncio.Event) -> None:
while not stop.is_set() and self.client.is_connected:
try:
request = await asyncio.wait_for(self.mota_requests.get(), 0.5)
except asyncio.TimeoutError:
continue
response = self.catalog.handle_request(request)
if response is None:
continue
chunk_size = self.mota_response_chunk_size
for offset in range(0, len(response), chunk_size):
await self.client.write_gatt_char(
MOTA_RESPONSE_UUID,
response[offset : offset + chunk_size],
response=True,
)
async def companion_command(self, frame: bytes, expected_length,
timeout: float = 8.0) -> bytes:
while not self.companion_chunks.empty():
self.companion_chunks.get_nowait()
await self.client.write_gatt_char(NUS_RX_UUID, frame, response=True)
deadline = asyncio.get_running_loop().time() + timeout
result = bytearray()
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise asyncio.TimeoutError
chunk = await asyncio.wait_for(
self.companion_chunks.get(), remaining
)
# Companion push frames have their own atomic BLE notification and
# use codes 0x80-0xff. They can arrive at any time, including while
# a command reply is pending, so do not splice one into the reply.
if not result and chunk and chunk[0] >= 0x80:
continue
result.extend(chunk)
wanted = expected_length(result)
if wanted is None:
continue
if len(result) != wanted:
raise RuntimeError(
f"malformed Companion response length {len(result)}/{wanted}"
)
return bytes(result)
async def local_control(self, command: str) -> str:
encoded = command.encode("ascii")
if not 1 <= len(encoded) <= 174:
raise ValueError("local command must be 1-174 ASCII bytes")
response = await self.companion_command(
bytes([CMD_EXEC_LOCAL_OTA_CONTROL]) + encoded,
lambda data: (
2 if data and data[0] == RESP_ERR
else 2 + data[1] if len(data) >= 2 and data[0] == RESP_OK
else 1 if data and data[0] not in (RESP_OK, RESP_ERR)
else None
),
)
if not response:
raise RuntimeError("empty Companion response")
if response[0] == RESP_ERR:
code = response[1] if len(response) > 1 else -1
raise RuntimeError(f"Companion rejected local command (error {code})")
if response[0] != RESP_OK:
raise RuntimeError(f"unexpected Companion response 0x{response[0]:02x}")
return response[2:].decode("ascii", errors="replace")
async def source_action(
self, action: int
) -> tuple[int, int, int, int | None]:
response = await self.companion_command(
bytes([CMD_BLE_MOTA_SOURCE, action]),
lambda data: (
2 if data and data[0] == RESP_ERR
else 11 if len(data) > 7 and data[0] == RESP_OK
else 7 if len(data) == 7 and data[0] == RESP_OK
else None if data and data[0] == RESP_OK
else 1 if data
else None
),
timeout=30.0,
)
return parse_source_status(response, action)
def parse_source_status(
response: bytes, expected_action: int
) -> tuple[int, int, int, int | None]:
if not response:
raise RuntimeError("empty Companion response")
if response[0] == RESP_ERR:
code = response[1] if len(response) > 1 else -1
raise RuntimeError(f"BLE mOTA source action failed (error {code})")
if (
len(response) not in (7, 11)
or response[0] != RESP_OK
or response[1] != expected_action
):
raise RuntimeError(f"malformed source status: {response.hex()}")
flags = response[2]
offered = struct.unpack_from("<H", response, 3)[0]
advertised = struct.unpack_from("<H", response, 5)[0]
packets_sent = (
struct.unpack_from("<I", response, 7)[0]
if len(response) == 11
else None
)
return flags, offered, advertised, packets_sent
async def resolve_device(device: str):
wanted = device.casefold()
found = await BleakScanner.find_device_by_filter(
lambda candidate, _advertisement: (
candidate.address.casefold() == wanted
or (candidate.name is not None and candidate.name == device)
),
timeout=10.0,
)
if found is None:
raise RuntimeError(f"Bluetooth device {device!r} was not found")
return found
def describe_status(
flags: int,
offered: int,
advertised: int,
packets_sent: int | None,
) -> str:
states = [
"channel-ready" if flags & MOTA_FLAG_CHANNEL_READY else "channel-not-ready",
"attached" if flags & MOTA_FLAG_ATTACHED else "detached",
]
if flags & MOTA_FLAG_ANOTHER_LINK_ACTIVE:
states.append("another-source-link-active")
packet_detail = (
f"; {packets_sent} LoRa packets sent"
if packets_sent is not None
else "; LoRa packet count unavailable (legacy firmware)"
)
return (
f"{', '.join(states)}; advertising {advertised}/{offered} files"
f"{packet_detail}"
)
async def run(args: argparse.Namespace) -> None:
if BLEAK_IMPORT_ERROR is not None:
raise RuntimeError(
"bleak is required; install it in an isolated environment, for "
"example with 'pipx runpip <environment> install bleak'"
) from BLEAK_IMPORT_ERROR
if args.directory is not None:
catalog = Catalog.scan(
args.directory, args.recursive, args.motatool, args.verbose
)
else:
catalog = Catalog([], args.verbose)
device = await resolve_device(args.device)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for signal_name in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(signal_name, stop.set)
except NotImplementedError:
pass
def disconnected(_client: BleakClient) -> None:
loop.call_soon_threadsafe(stop.set)
async with BleakClient(
device, disconnected_callback=disconnected, pair=args.pair
) as client:
session = BleSession(client, catalog)
await session.negotiate_mtu()
await client.start_notify(NUS_TX_UUID, session.on_companion_data)
needs_mota_channel = (
args.directory is not None or args.source == "start"
)
serve_task: asyncio.Task[None] | None = None
if needs_mota_channel:
await client.start_notify(MOTA_REQUEST_UUID, session.on_mota_request)
serve_task = asyncio.create_task(session.serve(stop))
source_started = False
temp_radio_requested = False
try:
for command in args.local:
print(await session.local_control(command))
if command.startswith("tempradio "):
temp_radio_requested = True
action = args.source
if action is None and args.directory is not None:
action = "start"
if action is not None:
action_code = {
"status": MOTA_ACTION_STATUS,
"start": MOTA_ACTION_START,
"stop": MOTA_ACTION_STOP,
}[action]
flags, offered, advertised, packets_sent = (
await session.source_action(action_code)
)
print(describe_status(
flags, offered, advertised, packets_sent
))
source_started = action == "start" and bool(
flags & MOTA_FLAG_ATTACHED
)
if source_started:
if args.seconds > 0:
try:
await asyncio.wait_for(stop.wait(), args.seconds)
except asyncio.TimeoutError:
pass
else:
print("Serving over Bluetooth; press Ctrl-C to stop")
await stop.wait()
finally:
if client.is_connected and source_started:
try:
flags, offered, advertised, packets_sent = (
await session.source_action(MOTA_ACTION_STOP)
)
print(describe_status(
flags, offered, advertised, packets_sent
))
except Exception as exc: # best effort during disconnect
print(f"warning: could not stop BLE source cleanly: {exc}",
file=sys.stderr)
if (client.is_connected and temp_radio_requested
and not args.leave_temp_radio):
try:
print(await session.local_control("normalradio"))
except Exception as exc: # best effort during disconnect
print(f"warning: could not restore normal radio: {exc}",
file=sys.stderr)
stop.set()
if serve_task is not None:
await serve_task
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Control and serve LoRa mOTA files through an nRF52 Full "
"Companion's encrypted Bluetooth link"
)
)
parser.add_argument(
"--device", required=True,
help="BLE address or exact advertised MeshCore device name",
)
parser.add_argument(
"--dir", dest="directory", type=Path,
help="folder of verified .mota files to serve",
)
parser.add_argument("--recursive", action="store_true")
parser.add_argument("--motatool", default="motatool")
parser.add_argument(
"--local", action="append", default=[], metavar="COMMAND",
help="run an allowed tempradio, normalradio, or ota command",
)
parser.add_argument(
"--source", choices=("status", "start", "stop"),
help="query or change BLE source state; --dir defaults to start",
)
parser.add_argument(
"--seconds", type=float, default=0,
help="stop serving after this many seconds (default: until Ctrl-C)",
)
parser.add_argument(
"--pair", action="store_true",
help="request pairing before accessing the MITM-protected service",
)
parser.add_argument(
"--leave-temp-radio", action="store_true",
help="do not send normalradio when this process exits",
)
parser.add_argument("--verbose", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.seconds < 0:
raise SystemExit("--seconds cannot be negative")
if args.source == "start" and args.directory is None:
raise SystemExit("--source start requires --dir")
try:
asyncio.run(run(args))
except (BleakError, OSError, RuntimeError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import unittest
import ble_mota_seeder as seeder
class SourceStatusTests(unittest.TestCase):
def test_parses_current_status_with_packet_count(self):
response = bytes.fromhex("0001030200020078563412")
self.assertEqual(
seeder.parse_source_status(response, seeder.MOTA_ACTION_START),
(3, 2, 2, 0x12345678),
)
def test_accepts_legacy_status_without_packet_count(self):
response = bytes.fromhex("00010302000200")
self.assertEqual(
seeder.parse_source_status(response, seeder.MOTA_ACTION_START),
(3, 2, 2, None),
)
def test_rejects_wrong_action_and_malformed_lengths(self):
current = bytes.fromhex("0001030200020078563412")
with self.assertRaisesRegex(RuntimeError, "malformed source status"):
seeder.parse_source_status(current, seeder.MOTA_ACTION_STOP)
with self.assertRaisesRegex(RuntimeError, "malformed source status"):
seeder.parse_source_status(current[:8], seeder.MOTA_ACTION_START)
def test_reports_firmware_error(self):
with self.assertRaisesRegex(RuntimeError, "error 4"):
seeder.parse_source_status(
bytes((seeder.RESP_ERR, 4)), seeder.MOTA_ACTION_START
)
class SourceActionTests(unittest.IsolatedAsyncioTestCase):
async def test_waits_for_and_parses_current_status(self):
response = bytes.fromhex("0001030200020078563412")
session = object.__new__(seeder.BleSession)
async def companion_command(frame, expected_length, timeout):
self.assertEqual(
frame,
bytes((seeder.CMD_BLE_MOTA_SOURCE, seeder.MOTA_ACTION_START)),
)
self.assertEqual(timeout, 30.0)
self.assertIsNone(expected_length(bytes((seeder.RESP_OK,))))
self.assertEqual(expected_length(response), 11)
return response
session.companion_command = companion_command
self.assertEqual(
await session.source_action(seeder.MOTA_ACTION_START),
(3, 2, 2, 0x12345678),
)
async def test_accepts_legacy_seven_byte_status(self):
response = bytes.fromhex("00000300000000")
session = object.__new__(seeder.BleSession)
async def companion_command(_frame, expected_length, timeout):
self.assertEqual(timeout, 30.0)
self.assertEqual(expected_length(response), 7)
return response
session.companion_command = companion_command
self.assertEqual(
await session.source_action(seeder.MOTA_ACTION_STATUS),
(3, 0, 0, None),
)
if __name__ == "__main__":
unittest.main()
+21 -2
View File
@@ -10,6 +10,7 @@ import io
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
@@ -2403,9 +2404,27 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
class MotatoolIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.motatool = os.environ.get("MOTATOOL_TEST_BIN")
candidates = [
os.environ.get("MOTATOOL_TEST_BIN"),
shutil.which("motatool"),
str(
Path(__file__).resolve().parents[3]
/ "motatool"
/ "target"
/ "release"
/ "motatool"
),
]
cls.motatool = next(
(candidate for candidate in candidates
if candidate and Path(candidate).is_file()),
None,
)
if not cls.motatool or not Path(cls.motatool).is_file():
raise unittest.SkipTest("set MOTATOOL_TEST_BIN to run motatool integration tests")
raise unittest.SkipTest(
"install/build motatool or set MOTATOOL_TEST_BIN to run "
"motatool integration tests"
)
subprocess.run([cls.motatool, "--version"], check=True, capture_output=True)
def test_raw_esp32_zip_becomes_full_mota(self) -> None:
+12 -5
View File
@@ -88,8 +88,11 @@ def test_full_esp32_profile_unifies_usb_logging_and_wifi_mqtt():
matrix = build.split("run_logging_matrix_build_targets()", 1)[1]
matrix = matrix.split("run_build_targets()", 1)[0]
assert 'is_companion_radio_full_target "$target"' in matrix
assert "input-capable single-TTY terminal" in matrix
assert 'uses_merged_standard_usb_logging "$target"' in matrix
assert 'run_full_esp32_profile "FULL unified pass"' in matrix
assert 'run_full_esp32_profile "FULL logging fallback pass"' in matrix
assert "Single-TTY boards first switch CDC 0" in mqtt_gate
assert "input-capable terminal" in mqtt_gate
def test_espnow_tx_power_matches_cli_callback_contract():
@@ -136,10 +139,14 @@ def test_flash_constrained_stm32_repeaters_pin_the_size_qualified_toolchain():
root / "examples/simple_repeater/MyMesh.cpp"
).read_text(encoding="utf-8")
assert "#if MESH_PACKET_LOGGING_COMPACT" in dispatcher
assert "Utils::printHex(logging_port, raw, len);" in dispatcher
assert 'line.printf("T");' in dispatcher
assert "line.hex(raw, len);" in dispatcher
assert "line.flush(usbLoggingPort(), false);" in dispatcher
assert "!MESH_PACKET_LOGGING_COMPACT" in dispatcher
assert "#if MESH_PACKET_LOGGING_COMPACT" in repeater
assert "mesh::Utils::printHex(logging_port, raw, len);" in repeater
assert 'line.printf("R");' in repeater
assert "line.hex(raw, len);" in repeater
assert "line.flush(mesh::usbLoggingPort(), false);" in repeater
def test_usb_companion_profiles_enable_the_usb_transport():
@@ -186,7 +193,7 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases():
redundant = build.split("is_redundant_bulk_build_target()", 1)[1]
redundant = redundant.split("resolve_logging_matrix_firmwares()", 1)[0]
assert "is_runtime_setting_alias_target" in redundant
assert "is_companion_transport_replaced_by_full" in redundant
assert "is_firmware_role_replaced_by_canonical_artifact" in redundant
logging_matrix = build.split("resolve_logging_matrix_firmwares()", 1)[1]
logging_matrix = logging_matrix.split("resolve_companion_firmwares()", 1)[0]
+6
View File
@@ -79,6 +79,12 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
bakercp/CRC32 @ ^2.0.0
[env:Heltec_E290_companion_usb_ble]
extends = env:Heltec_E290_companion_usb
build_flags =
${env:Heltec_E290_companion_usb.build_flags}
-D BLE_PIN_CODE=123456
[env:Heltec_E290_repeater]
extends = Heltec_E290_base
build_flags =
+14
View File
@@ -63,6 +63,7 @@ upload_protocol = nrfutil
[env:Heltec_t096_repeater]
extends = Heltec_t096
build_src_filter = ${Heltec_t096.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
@@ -74,6 +75,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; -D POWERSAVING_DEBUG=1
@@ -82,6 +88,7 @@ build_flags =
[env:Heltec_t096_repeater_lora_ota_no_external_sensors]
extends = Heltec_t096
build_src_filter = ${Heltec_t096.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
${Heltec_t096.build_flags}
@@ -92,6 +99,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
lib_deps =
${nrf52_lora_ota.lib_deps}
${nrf52_no_external_sensors_keep_gps.lib_deps}
@@ -109,6 +121,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
; -D BRIDGE_DEBUG=1
@@ -131,6 +144,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
build_src_filter = ${Heltec_t096.build_src_filter}
+20
View File
@@ -50,6 +50,7 @@ upload_protocol = nrfutil
[env:Heltec_t114_without_display_repeater]
extends = Heltec_t114
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
${Heltec_t114.build_flags}
@@ -58,6 +59,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
@@ -71,6 +77,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
; -D BRIDGE_DEBUG=1
@@ -163,6 +170,7 @@ upload_protocol = nrfutil
[env:Heltec_t114_repeater]
extends = Heltec_t114_with_display
build_src_filter = ${Heltec_t114_with_display.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
@@ -174,6 +182,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; -D POWERSAVING_DEBUG=1
@@ -182,6 +195,7 @@ build_flags =
[env:Heltec_t114_repeater_lora_ota_no_external_sensors]
extends = Heltec_t114_with_display
build_src_filter = ${Heltec_t114_with_display.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
${Heltec_t114_with_display.build_flags}
@@ -192,6 +206,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
lib_deps =
${nrf52_lora_ota.lib_deps}
${nrf52_no_external_sensors_keep_gps.lib_deps}
@@ -207,6 +226,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
; -D BRIDGE_DEBUG=1
+6
View File
@@ -83,6 +83,12 @@ lib_deps =
${Heltec_T190_base.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:Heltec_T190_companion_radio_usb_ble_]
extends = env:Heltec_T190_companion_radio_usb_
build_flags =
${env:Heltec_T190_companion_radio_usb_.build_flags}
-D BLE_PIN_CODE=123456
[env:Heltec_T190_repeater_]
extends = Heltec_T190_base
build_flags =
+14
View File
@@ -48,6 +48,11 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
extra_scripts =
@@ -57,6 +62,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
-<helpers/bridges/MQTTBridge.cpp>
-<helpers/MQTTMessageBuilder.cpp>
@@ -77,6 +83,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
; -D BRIDGE_DEBUG=1
@@ -391,9 +398,15 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Heltec_lora32_v3.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
-<helpers/bridges/MQTTBridge.cpp>
-<helpers/MQTTMessageBuilder.cpp>
@@ -413,6 +426,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
; -D BRIDGE_DEBUG=1
@@ -56,6 +56,7 @@ extends = LilyGo_TLora_V2_1_1_6
; upload handler instead of ElegantOTA on this ultra-tight 4 MB target.
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
${LilyGo_TLora_V2_1_1_6.build_flags}
@@ -67,6 +68,11 @@ build_flags =
-D MAX_NEIGHBOURS=50
-D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; ultra-tight classic ESP32: three wildcards plus one exact channel
-D LIGHTWEIGHT_WIFI_OTA=1
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=34
-D WITH_RS232_BRIDGE_TX=25
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; -D CORE_DEBUG_LEVEL=3
@@ -296,6 +302,7 @@ build_flags =
-D MAX_NEIGHBOURS=50
-D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with bridge logging
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=34
-D WITH_RS232_BRIDGE_TX=25
-D DISABLE_LORA_OTA=1 ; RS232 bridge plus LoRa OTA overflows classic ESP32 DRAM
+8
View File
@@ -42,6 +42,7 @@ build_src_filter = ${Promicro.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/bridges/RS232Bridge.cpp>
build_flags =
${Promicro.build_flags}
-D ADVERT_NAME='"ProMicro Repeater"'
@@ -50,6 +51,12 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D DISPLAY_CLASS=SSD1306Display
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
-D RS232_BRIDGE_MERGED=1
-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
lib_deps = ${Promicro.lib_deps}
@@ -71,6 +78,7 @@ build_flags =
-D MAX_NEIGHBOURS=50
-D DISPLAY_CLASS=SSD1306Display
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
-UENV_INCLUDE_GPS
+7
View File
@@ -44,9 +44,15 @@ build_flags = ${rak11310.build_flags}
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=8
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${rak11310.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
[env:RAK_11310_repeater_bridge_rs232]
@@ -58,6 +64,7 @@ build_flags = ${rak11310.build_flags}
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=8
; -D BRIDGE_DEBUG=1
+7
View File
@@ -47,9 +47,15 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${rak3112.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
lib_deps =
${rak3112.lib_deps}
@@ -66,6 +72,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=5
-D WITH_RS232_BRIDGE_TX=6
; -D BRIDGE_DEBUG=1
+40
View File
@@ -55,12 +55,23 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX
-D WITH_RS232_BRIDGE_ALT=Serial1
-D WITH_RS232_BRIDGE_ALT_UART=1
-D WITH_RS232_BRIDGE_ALT_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_ALT_TX=PIN_SERIAL1_TX
-D RS232_BRIDGE_MERGED=1
-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1
; -D OTA_DEBUG=1 ; bring-up: trace OTA fetch (REQ / block / page-flush) over Serial
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; -D POWERSAVING_DEBUG=1
build_src_filter = ${rak4631.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/bridges/RS232Bridge.cpp>
; Shared sources remain inherited, but ENABLE_OTA is explicitly undefined for this oversized role.
+<../examples/simple_repeater>
@@ -104,11 +115,22 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX
-D WITH_RS232_BRIDGE_ALT=Serial1
-D WITH_RS232_BRIDGE_ALT_UART=1
-D WITH_RS232_BRIDGE_ALT_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_ALT_TX=PIN_SERIAL1_TX
-D RS232_BRIDGE_MERGED=1
-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1
build_src_filter = ${nrf52_lora_ota.build_src_filter}
+<../variants/rak4631>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
lib_deps =
${nrf52_lora_ota.lib_deps}
@@ -150,6 +172,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
-UENV_INCLUDE_GPS
@@ -176,6 +199,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
build_src_filter = ${rak4631.build_src_filter}
@@ -200,6 +224,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX
-UENV_INCLUDE_GPS
@@ -226,6 +251,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX
build_src_filter = ${rak4631.build_src_filter}
@@ -332,6 +358,20 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip
[env:RAK_4631_companion_radio_full]
extends = env:RAK_4631_companion_radio_usb
build_flags =
${env:RAK_4631_companion_radio_usb.build_flags}
-D ETHERNET_ENABLED=1
-D ETHERNET_USE_RAK13800
-D ETHERNET_CLASS=RAK13800EthernetInterface
build_src_filter = ${env:RAK_4631_companion_radio_usb.build_src_filter}
+<helpers/ethernet/*.cpp>
+<helpers/ethernet/RAK13800/>
lib_deps =
${env:RAK_4631_companion_radio_usb.lib_deps}
https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip
[env:RAK_4631_companion_radio_ble]
extends = rak4631
+7
View File
@@ -217,6 +217,13 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
[env:ThinkNode_M2_companion_radio_full]
extends = env:ThinkNode_M2_companion_radio_wifi
build_flags =
${env:ThinkNode_M2_companion_radio_wifi.build_flags}
-D SERIAL_TX=43
-D SERIAL_RX=44
[env:ThinkNode_M2_kiss_modem]
extends = ThinkNode_M2
build_flags = ${ThinkNode_M2.build_flags}
+7
View File
@@ -233,6 +233,13 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
[env:ThinkNode_M5_companion_radio_full]
extends = env:ThinkNode_M5_companion_radio_wifi
build_flags =
${env:ThinkNode_M5_companion_radio_wifi.build_flags}
-D SERIAL_TX=43
-D SERIAL_RX=44
[env:ThinkNode_M5_kiss_modem]
extends = ThinkNode_M5
build_src_filter = ${ThinkNode_M5.build_src_filter}
+11
View File
@@ -175,6 +175,17 @@ lib_deps = ${ThinkNode_M7.lib_deps}
${ThinkNode_M7_ethernet.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:ThinkNode_M7_companion_radio_full]
extends = env:ThinkNode_M7_companion_radio_wifi
build_flags =
${env:ThinkNode_M7_companion_radio_wifi.build_flags}
${ThinkNode_M7_ethernet.build_flags}
build_src_filter = ${env:ThinkNode_M7_companion_radio_wifi.build_src_filter}
${ThinkNode_M7_ethernet.build_src_filter}
lib_deps =
${env:ThinkNode_M7_companion_radio_wifi.lib_deps}
${ThinkNode_M7_ethernet.lib_deps}
[env:ThinkNode_M7_kiss_modem]
extends = ThinkNode_M7
build_src_filter = ${ThinkNode_M7.build_src_filter}
@@ -43,9 +43,15 @@ build_flags = ${waveshare_rp2040_lora.build_flags}
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=8
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${waveshare_rp2040_lora.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
[env:waveshare_rp2040_lora_repeater_bridge_rs232]
@@ -57,6 +63,7 @@ build_flags = ${waveshare_rp2040_lora.build_flags}
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=8
; -D BRIDGE_DEBUG=1
+1
View File
@@ -78,6 +78,7 @@ build_flags = ${lora_e5.build_flags}
-D FLOOD_CHANNEL_SCOPE_SLOTS=7
-D ENABLE_HWSERIAL2
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=PA3
-D WITH_RS232_BRIDGE_TX=PA2
build_src_filter = ${lora_e5.build_src_filter}
+9
View File
@@ -213,7 +213,14 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
-D BRIDGE_MAX_BAUD=500000
-D RS232_BRIDGE_MERGED=1
build_src_filter = ${solarxiao.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater/*.cpp>
[env:solarxiao_33S_repeater]
@@ -236,9 +243,11 @@ build_flags =
${env:solarxiao_30S_repeater.build_flags}
-D ADVERT_NAME='"SolarXiao 30S RS232"'
-D WITH_RS232_BRIDGE=Serial1
-D WITH_RS232_BRIDGE_UART=1
-D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX
-D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX
-D BRIDGE_MAX_BAUD=500000
-D RS232_BRIDGE_DEFAULT_ON=1
build_src_filter = ${env:solarxiao_30S_repeater.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+2
View File
@@ -313,6 +313,8 @@ build_unflags = ${esp32_s3_dual_cdc_full.build_unflags}
build_flags =
${env:Xiao_S3_WIO_companion_radio_wifi.build_flags}
${esp32_s3_dual_cdc_full.build_flags}
-D SERIAL_TX=D6
-D SERIAL_RX=D7
[env:Xiao_S3_WIO_sensor]
extends = Xiao_S3_WIO