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 committed 2026-08-27 01:22:18 -07:00
1 parent 91870e4bb0
commit e5fd77e53f
79 files changed
+3178 -304

No files matched your search

+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.