diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index be8c8384..7314407a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -114,7 +114,9 @@ jobs: run: node scripts/test_filter_tool.js - name: Verify bridge filter wiring and shorthand permissions - run: python3 -B test/test_transport_filter_wiring.py -v + run: | + python3 -B test/test_transport_filter_wiring.py -v + python3 -B test/test_rs232_runtime.py -v - name: Verify saved repeat-off trace exception and relay guards run: | diff --git a/build_legacy.sh b/build_legacy.sh index d658b8bc..9c1f869e 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3383,6 +3383,12 @@ declare_build_capability_contract() { declare_full_logging_application_contract "$env_name" record_build_capability "profile.${BUILD_PROFILE_FOR_TARGET}" + if [ "$env_name_lc" = mke_s3_repeater ]; then + # Do not remove the dedicated bridge images unless both real transports + # survived linking in the canonical runtime-selectable repeater. + record_build_expectation "bridge.rs232" "_ZN11RS232Bridge5beginEv" + record_build_expectation "bridge.espnow" "_ZN12ESPNowBridge5beginEv" + fi if is_nrf52_sensor_ota_pair_target "$env_name" \ && [ -n "${NRF52_OTA_SENSOR_PROFILE:-}" ]; then record_build_capability "sensor.profile.${NRF52_OTA_SENSOR_PROFILE}" @@ -5488,6 +5494,9 @@ get_merged_rs232_repeater_replacement() { heltec_wsl3_repeater_bridge_rs232) printf '%s\n' Heltec_WSL3_repeater ;; + mke_s3_repeater_bridge_rs232) + printf '%s\n' MKE_s3_repeater + ;; lilygo_tlora_v2_1_1_6_repeater_bridge_rs232) printf '%s\n' LilyGo_TLora_V2_1_1_6_repeater ;; @@ -5505,7 +5514,15 @@ 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 + || get_merged_rs232_repeater_replacement "$1" >/dev/null \ + || get_merged_espnow_repeater_replacement "$1" >/dev/null +} + +get_merged_espnow_repeater_replacement() { + case "${1,,}" in + mke_s3_repeater_bridge_espnow) printf '%s\n' MKE_s3_repeater ;; + *) return 1 ;; + esac } is_runtime_setting_alias_target() { @@ -5591,7 +5608,7 @@ resolve_full_companion_firmwares() { resolve_repeater_firmwares() { local env_name while IFS= read -r env_name; do - if ! get_unified_meshtower_repeater_replacement "$env_name" >/dev/null; then + if ! is_redundant_bulk_build_target "$env_name"; then printf '%s\n' "$env_name" fi done < <(get_pio_envs_for_variant_role repeater) @@ -5613,7 +5630,8 @@ resolve_full_esp32_firmwares() { local env_name for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do - if supports_esp32_full_build "$env_name"; then + if supports_esp32_full_build "$env_name" \ + && ! is_redundant_bulk_build_target "$env_name"; then printf '%s\n' "$env_name" fi done @@ -6326,7 +6344,31 @@ get_ordinary_full_group_key() { *_repeater_observer_mqtt|*_room_server_observer_mqtt) role_base=${base%_observer_mqtt} ;; *_repeater_bridge_espnow) - role_base=${base%_bridge_espnow} ;; + # Merged repeaters and observer-backed Full images include ESP-NOW at + # runtime. Keep dedicated images for boards without that replacement. + local combined_target="" + local repeater_base=${base%_bridge_espnow} + combined_target=$(get_merged_espnow_repeater_replacement "$target") || combined_target="" + if [ -n "$combined_target" ] \ + && is_supported_build_env "$combined_target" \ + && supports_esp32_full_build "$combined_target" \ + && [ -n "${PIO_ENV_BOARD_BY_NAME[$target]:-}" ] \ + && [ "${PIO_ENV_BOARD_BY_NAME[$target]}" = "${PIO_ENV_BOARD_BY_NAME[$combined_target]:-}" ]; then + role_base=$repeater_base + else + combined_target=$(get_mqtt_enabled_target "$repeater_base") || combined_target="" + if [ -n "$combined_target" ] \ + && supports_esp32_full_build "$combined_target" \ + && [ -n "${PIO_ENV_BOARD_BY_NAME[$target]:-}" ] \ + && [ "${PIO_ENV_BOARD_BY_NAME[$target]}" = "${PIO_ENV_BOARD_BY_NAME[$combined_target]:-}" ]; then + role_base=$repeater_base + elif is_mqtt_bridge_target "${repeater_base}_observer_mqtt_" \ + && supports_esp32_full_build "${repeater_base}_observer_mqtt_" \ + && [ -n "${PIO_ENV_BOARD_BY_NAME[$target]:-}" ] \ + && [ "${PIO_ENV_BOARD_BY_NAME[$target]}" = "${PIO_ENV_BOARD_BY_NAME[${repeater_base}_observer_mqtt_]:-}" ]; then + role_base=$repeater_base + fi + fi ;; *_repeater|*_room_server) ;; *) role_base=$base ;; esac diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 180f24b4..6ed07a55 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -185,6 +185,25 @@ available from a canonical image: 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. + `MKE_s3_repeater` includes the bridge on UART 2, with RX on GPIO16 and TX on + GPIO17. Fresh installs leave it disabled at 115200 baud; upgrades from normal + repeaters keep it disabled, while existing saved RS-232 settings are retained. + Use `set bridge.baud 115200` and `set bridge.enabled on` to enable it, or + `set bridge.enabled off` to stop it. `set bridge.uart 2` selects its fixed + UART; other UART numbers are rejected. Successful changes take effect + immediately and persist across reboots. The historical + `MKE_s3_repeater_bridge_rs232` name remains directly buildable with its + bridge-on default for compatibility but is omitted from bulk releases. + The same MKE image includes ESP-NOW, controlled independently with + `set espnow.enabled on|off` and `get espnow.running`. Both bridges can run + together and share `bridge.source` and `bridge.delay`; `bridge.channel`, + `bridge.secret`, and `bridge.format` apply only to ESP-NOW. Its first boot + after an upgrade leaves ESP-NOW off, including upgrades from the dedicated + ESP-NOW image: run `set espnow.enabled on` to enable it. Subsequent reboots + retain the saved setting. The historical `MKE_s3_repeater_bridge_espnow` + target remains directly buildable with its bridge-on default but is omitted + from bulk releases. Browser OTA pauses ESP-NOW while leaving RS-232 running; + `stop ota` restores ESP-NOW when its saved setting is on. - MeshTower V2 repeaters use `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` as the one canonical target. Its SD OTA target `0A9DBBF0`, hardware ID diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a5436981..351160e4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -722,6 +722,9 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender reply_data[8] = 0; // features #ifdef WITH_RS232_BRIDGE if (isBridgeRunning()) reply_data[8] |= 0x01; // is bridge, type UART +#ifdef WITH_ESPNOW_BRIDGE + if (isEspNowBridgeRunning()) reply_data[8] |= 0x03; // ESP-NOW may run alongside UART +#endif #elif WITH_ESPNOW_BRIDGE if (isBridgeRunning()) reply_data[8] |= 0x03; // is bridge, type ESP-NOW #endif @@ -1187,13 +1190,6 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->onPacketReceived(pkt); - #ifdef WITH_ESPNOW_BRIDGE - // ESP-NOW follows bridge.source, independently of MQTT's mqtt.rx policy. - ESPNowBridge* espnow = &espnow_bridge; - if (_prefs.bridge_pkt_src == 1 && espnow && espnow->isRunning()) { - espnow->sendPacket(pkt); - } - #endif #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting AbstractBridge* active_bridge = activeBridge(); @@ -1202,6 +1198,13 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { active_bridge->sendPacket(pkt); } #endif +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) + // ESP-NOW follows bridge.source independently of the other transport's state. + if (_prefs.bridge_pkt_src == 1 && espnow_bridge.isRunning()) { + espnow_bridge.sendPacket(pkt); + } +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -1239,12 +1242,6 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed TX packets - bridge decides based on mqtt.tx setting if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->sendPacket(pkt); - #ifdef WITH_ESPNOW_BRIDGE - ESPNowBridge* espnow = &espnow_bridge; - if (_prefs.bridge_pkt_src == 0 && espnow && espnow->isRunning()) { - espnow->sendPacket(pkt); - } - #endif #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting AbstractBridge* active_bridge = activeBridge(); @@ -1253,6 +1250,12 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { active_bridge->sendPacket(pkt); } #endif +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) + if (_prefs.bridge_pkt_src == 0 && espnow_bridge.isRunning()) { + espnow_bridge.sendPacket(pkt); + } +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -3463,6 +3466,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif #elif defined(WITH_RS232_BRIDGE) , bridge(nullptr) + #if defined(WITH_ESPNOW_BRIDGE) + , espnow_bridge(&_prefs, _mgr, &rtc) + , shared_espnow_retry_at(0) + #endif #elif defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) #endif @@ -3652,7 +3659,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #else _prefs.bridge_enabled = 1; // enabled #endif +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + _prefs.espnow_bridge_enabled = 0; // merged normal repeater until explicitly enabled +#else _prefs.espnow_bridge_enabled = 1; // preserves the Full image's former combined default +#endif _prefs.bridge_delay = 500; // milliseconds _prefs.bridge_pkt_src = 1; // logRx (RX packets) _prefs.bridge_baud = 115200; // baud rate @@ -3865,7 +3876,8 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true); #endif mesh::hilStartupTrace("mesh_bridge_ready"); @@ -12818,13 +12830,16 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { #if defined(WITH_ESPNOW_BRIDGE) // MQTT runs on Core 0. ESP-NOW remains cooperative, including in the // combined Full image where both transports share the WiFi station radio. - #if defined(WITH_MQTT_BRIDGE) + #if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.loop(); else startSharedEspNowBridgeIfReady(); #else if (bridge.isRunning()) bridge.loop(); #endif -#elif defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) +#endif +#if defined(WITH_RS232_BRIDGE) \ + || (defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) \ + && !defined(WITH_ESPNOW_BRIDGE)) AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) active_bridge->loop(); #endif @@ -13492,7 +13507,8 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) return true; -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (espnow_bridge.isRunning()) return true; #endif #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d1d7eca8..9fb4ad23 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -586,6 +586,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #elif defined(WITH_RS232_BRIDGE) RS232Bridge* bridge; uint8_t active_rs232_bridge_uart = 0; + #if defined(WITH_ESPNOW_BRIDGE) + ESPNowBridge espnow_bridge; + uint32_t shared_espnow_retry_at = 0; + #endif #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; #endif @@ -610,17 +614,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks } #endif -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - // MQTT creates its WiFi task asynchronously. Do not let ESP-NOW touch the - // station interface until that task has associated, otherwise it could bring - // up a second WiFi owner before the AP selects the shared channel. +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) + // Retry ESP-NOW without taking over an active OTA session. MQTT creates its + // WiFi task asynchronously, so it must first associate on the shared channel. bool startSharedEspNowBridgeIfReady() { if (espnow_bridge.isRunning()) return true; if (!_prefs.espnow_bridge_enabled) return false; // Browser OTA pauses the bridges for the entire upload session. The // periodic retry must not undo that pause on its next loop iteration. if (_cli.getBoard()->isOTAUpdateRunning()) return false; +#ifdef WITH_MQTT_BRIDGE if (mqtt_bridge && mqtt_bridge->isRunning() && !WiFi.isConnected()) return false; +#endif if (!millisHasNowPassed(shared_espnow_retry_at)) return false; shared_espnow_retry_at = millis() + 5000; configureBridgeFilter(&espnow_bridge); @@ -1277,11 +1283,13 @@ public: #endif } -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) bool isEspNowBridgeRunning() override { return espnow_bridge.isRunning(); } +#ifdef WITH_MQTT_BRIDGE bool setMqttBridgeState(bool enable) override { if (!enable) { if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); @@ -1322,6 +1330,7 @@ public: if (_prefs.espnow_bridge_enabled) startSharedEspNowBridgeIfReady(); return mqtt_bridge->isRunning(); } +#endif bool setEspNowBridgeState(bool enable) override { if (!enable) { @@ -1330,18 +1339,22 @@ public: return !espnow_bridge.isRunning(); } if (espnow_bridge.isRunning()) return true; + if (_cli.getBoard()->isOTAUpdateRunning()) return false; shared_espnow_retry_at = 0; +#ifdef WITH_MQTT_BRIDGE if (mqtt_bridge && mqtt_bridge->isRunning()) { // Association is asynchronous; servicePostMeshLoop retries once the // MQTT station has joined the access point on its fixed channel. startSharedEspNowBridgeIfReady(); return true; } +#endif configureBridgeFilter(&espnow_bridge); espnow_bridge.begin(); return espnow_bridge.isRunning(); } +#ifdef WITH_MQTT_BRIDGE bool restartMqttBridge() override { #ifdef WITH_WEBCONFIG if (_wc_batch_active) { @@ -1355,6 +1368,7 @@ public: _alerter.setBridge(nullptr); return !_prefs.bridge_enabled || setMqttBridgeState(true); } +#endif bool restartEspNowBridge() override { if (espnow_bridge.isRunning()) espnow_bridge.end(); diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index 4b8090d7..bb3c5c24 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -122,11 +122,55 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: if targets & tower_legacy and tower_primary not in targets: raise ValueError("MeshTower V2 release requires the SD primary; old internal-only " "artifacts cannot substitute for its distinct OTA identity") + + mke_primary = "mke_s3_repeater" + mke_legacy = {mke_primary + "_bridge_rs232", mke_primary + "_bridge_espnow"} + mke_bridges = {"bridge.rs232", "bridge.espnow"} + + def qualified_full(manifest: dict) -> bool: + return (manifest["platform"] == "ESP32_PLATFORM" + and manifest["build_profile"] == "full" + and manifest.get("verified") is True) + + # A resumed build directory can still contain old normal or dedicated + # bridge images. The canonical Full image must prove both runtime bridges + # even when no stale legacy image happens to remain beside it. + mke_full = [record["manifest"] for record in records + if record["manifest"]["target"].lower() == mke_primary + and record["manifest"]["build_profile"] == "full"] + if mke_full or targets & mke_legacy: + if not mke_full or any( + not qualified_full(manifest) + or not mke_bridges <= set(manifest.get("capabilities") or []) + or not mke_bridges <= { + check["capability"] for check in manifest.get("verification") or [] + if check.get("present") is True + } + for manifest in mke_full + ): + raise ValueError("MKE S3 release requires a qualified combined repeater " + "with verified RS232 and ESP-NOW bridges; old normal or " + "legacy bridge artifacts cannot replace that canonical image") + + # A dedicated ESP-NOW image is redundant only when the matching normal or + # observer Full image really advertises that capability. Filename suffixes + # alone previously suppressed V4 TFT's only ESP-NOW implementation. + combined_espnow = set() + for record in records: + manifest = record["manifest"] + if not qualified_full(manifest) or "bridge.espnow" not in (manifest.get("capabilities") or []): + continue + base = manifest["target"].rstrip("_").lower() + if base.endswith("_observer_mqtt"): + base = base[:-len("_observer_mqtt")] + if base.endswith("_repeater"): + combined_espnow.add(base) + chosen: dict[str, tuple[int, dict]] = {} passthrough: list[dict] = [] for record in records: manifest = record["manifest"] - if manifest["target"].lower() in tower_legacy: + if manifest["target"].lower() in tower_legacy | mke_legacy: continue if manifest["platform"] != "ESP32_PLATFORM" or manifest["build_profile"] != "full": passthrough.append(record) @@ -138,7 +182,11 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: base = base[:-len("_observer_mqtt")] kind = "observer" elif base.lower().endswith("_bridge_espnow"): - base = base[:-len("_bridge_espnow")] + bridge_base = base[:-len("_bridge_espnow")] + if bridge_base.lower() in combined_espnow or bridge_base.lower() in { + "meshadventurer_sx1262_repeater", "meshadventurer_sx1268_repeater", + }: + base = bridge_base kind = "bridge" key = base.lower() # G2's observer is the deployed Full identity. The other listed diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 1bd2fb49..dadde551 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -824,14 +824,49 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { File legacy = fs->open("/prefs.json", FILE_O_READ); #else File legacy = fs->open("/prefs.json", "r"); +#endif +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) + const uint8_t previous_bridge_uart = _prefs->bridge_uart; + const uint8_t previous_bridge_enabled = _prefs->bridge_enabled; + // JSON profiles can contain the unused enabled flag without any UART. + // Require an explicit nonzero UART before treating it as bridge intent. + _prefs->bridge_uart = 0; +#endif +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + const uint8_t previous_espnow_enabled = _prefs->espnow_bridge_enabled; #endif if (legacy && _prefs->loadSerial(legacy)) { loaded = true; is_upgrade = true; +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) + if (_prefs->bridge_uart == 0) _prefs->bridge_enabled = 0; + 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; + } +#endif +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + // The legacy JSON schema has no independent ESP-NOW intent marker. + _prefs->espnow_bridge_enabled = 0; +#endif #ifdef WITH_MQTT_BRIDGE node_prefs_needs_migration = true; #else savePrefs(fs); +#endif + } else { +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) + _prefs->bridge_uart = previous_bridge_uart; + _prefs->bridge_enabled = previous_bridge_enabled; +#endif +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + _prefs->espnow_bridge_enabled = previous_espnow_enabled; #endif } if (legacy) legacy.close(); @@ -972,6 +1007,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { #endif mesh::hilStartupTrace("prefs_image_open_ready"); if (file) { +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + bool has_runtime_espnow_intent = false; +#endif #if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ && !defined(RS232_BRIDGE_DEFAULT_ON) bool has_runtime_bridge_uart = false; @@ -1370,6 +1408,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->ota_channel)) { file.read((uint8_t *)&_prefs->ota_channel, sizeof(_prefs->ota_channel)); +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + uint8_t bridge_profile = 0; + if (file.read(&bridge_profile, sizeof(bridge_profile)) == sizeof(bridge_profile)) { + has_runtime_espnow_intent = bridge_profile == 0xA1; + } +#endif } } } @@ -1438,12 +1482,21 @@ 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(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + if (!has_runtime_espnow_intent) { + // Older profiles saved an unused ESP-NOW flag as on. Only the appended + // combined-profile marker proves that this was an explicit setting. + _prefs->espnow_bridge_enabled = 0; + _com_prefs_needs_upgrade = true; + } +#endif #if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ && !defined(RS232_BRIDGE_DEFAULT_ON) - if (!has_runtime_bridge_uart) { + if (!has_runtime_bridge_uart || _prefs->bridge_uart == 0) { // 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. + // bridge was compiled, with either no UART tail or the no-bridge zero + // sentinel. Fail safe before normalizing the UART instead of claiming + // it unexpectedly; the user can explicitly enable the bridge. _prefs->bridge_enabled = 0; _com_prefs_needs_upgrade = true; } @@ -1710,6 +1763,10 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->usb_debug_enabled); // appended USB debug intent WRITE_COMMON_PREFS(&prefs->trace_when_repeat_off); // appended repeater trace exception WRITE_COMMON_PREFS(&prefs->ota_channel); // appended WiFi OTA channel; never shift older fields +#ifdef ESPNOW_BRIDGE_MERGED + const uint8_t bridge_profile = 0xA1; + WRITE_COMMON_PREFS(&bridge_profile); // independent ESP-NOW intent belongs to this profile +#endif #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1873,6 +1930,10 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { file.write((uint8_t *)&_prefs->usb_debug_enabled, sizeof(_prefs->usb_debug_enabled)); // appended file.write((uint8_t *)&_prefs->trace_when_repeat_off, sizeof(_prefs->trace_when_repeat_off)); // appended file.write((uint8_t *)&_prefs->ota_channel, sizeof(_prefs->ota_channel)); // appended WiFi OTA channel +#ifdef ESPNOW_BRIDGE_MERGED + const uint8_t bridge_profile = 0xA1; + file.write(&bridge_profile, sizeof(bridge_profile)); // independent ESP-NOW intent belongs to this profile +#endif _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -2783,16 +2844,40 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } webconfig_stopped = true; } +#endif +#if defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) \ + && !defined(WITH_MQTT_BRIDGE) + // Browser OTA owns the shared WiFi driver, but the independent UART + // bridge can keep running throughout the upload. + const bool espnow_paused = _callbacks->isEspNowBridgeRunning(); + if (espnow_paused && !_callbacks->setEspNowBridgeState(false)) { + strcpy(reply, "ERR: could not pause ESP-NOW for OTA"); + return; + } #endif reply[0] = 0; if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { if (!reply[0]) strcpy(reply, "Error"); +#if defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) \ + && !defined(WITH_MQTT_BRIDGE) + if (espnow_paused && !_callbacks->setEspNowBridgeState(true)) { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; ESP-NOW resume failed"); + } +#endif } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) else { // Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs. _callbacks->setBridgeState(false); } +#endif +#if defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) \ + && !defined(WITH_MQTT_BRIDGE) + else if (espnow_paused) { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; ESP-NOW paused"); + } #endif if (webconfig_stopped) { const size_t used = strlen(reply); @@ -2810,6 +2895,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re ) { _callbacks->setBridgeState(true); } +#endif +#if defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) \ + && !defined(WITH_MQTT_BRIDGE) + else if (_prefs->espnow_bridge_enabled) { + const bool resumed = _callbacks->setEspNowBridgeState(true); + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, resumed + ? "; ESP-NOW resumed" : "; ESP-NOW resume failed"); + } #endif } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); @@ -3668,15 +3762,19 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep #endif // Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp. if (handleObserverSetCmd(sender_timestamp, config, reply)) return; -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (memcmp(config, "espnow.enabled ", 15) == 0) { const char* value = config + 15; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { strcpy(reply, "Error: use set espnow.enabled on|off"); } else { - char canonical[28]; - snprintf(canonical, sizeof(canonical), "set bridge.enabled %s", value); - handleSetCmd(sender_timestamp, canonical, reply); + const bool enable = strcmp(value, "on") == 0; + _prefs->espnow_bridge_enabled = enable; + const bool applied = _callbacks->setEspNowBridgeState(enable); + savePrefs(); + strcpy(reply, applied ? "OK" + : "Error: ESP-NOW runtime change failed; setting saved"); } return; } @@ -4711,7 +4809,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep &config[15], _prefs->bridge_format, channel)) { _prefs->bridge_channel = channel; const bool applied = -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); #else !_prefs->bridge_enabled || _callbacks->restartBridge(); @@ -4731,7 +4829,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret)); const bool applied = -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); #else !_prefs->bridge_enabled || _callbacks->restartBridge(); @@ -4750,7 +4848,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { _prefs->bridge_format = format; const bool applied = -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); #else !_prefs->bridge_enabled || _callbacks->restartBridge(); @@ -5195,6 +5293,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", #if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) "mqtt+espnow" +#elif defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + "rs232+espnow" #elif defined(WITH_RS232_BRIDGE) "rs232" #elif WITH_ESPNOW_BRIDGE @@ -5220,7 +5320,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #else _callbacks->isBridgeRunning() ? "on" : "off"); #endif -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) \ + && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) } else if (configKeyEquals(config, "espnow.enabled")) { sprintf(reply, "> %s", _prefs->espnow_bridge_enabled ? "on" : "off"); } else if (configKeyEquals(config, "espnow.running")) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 57520652..128f2ce1 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -197,10 +197,11 @@ public: // Appended after the existing /com_prefs tail. Keep the preamble in the same // transaction as frequency/modulation; old images adopt /radio_profiles. uint16_t primary_radio_preamble = 0; - // Full ESP32 images can carry MQTT and ESP-NOW simultaneously. Keep the + // Full ESP32 images can carry another bridge and ESP-NOW simultaneously. Keep the // ESP-NOW intent separately so either transport can be selected at runtime. // Old preference images did not have this byte; they default to enabled to - // retain the previous combined-Full behavior after an upgrade. + // retain the previous MQTT combined-Full behavior after an upgrade. Merged + // default-off profiles require their appended profile marker instead. uint8_t espnow_bridge_enabled = 1; // Appended after the ESP-NOW intent. Zero keeps the board's legacy cadence. uint16_t gps_sync_interval_hours = 0; @@ -593,7 +594,7 @@ public: virtual bool isBridgeRunning() const { return false; } // Most roles have one bridge, so the transport-specific methods retain the - // legacy bridge behavior by default. Combined MQTT + ESP-NOW roles override + // legacy bridge behavior by default. Combined bridge roles override // them to let each transport be started, stopped, and queried independently. virtual bool setMqttBridgeState(bool enable) { return setBridgeState(enable); } virtual bool setEspNowBridgeState(bool enable) { return setBridgeState(enable); } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 11b406e8..efb37958 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -303,7 +303,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { const IPAddress ap_ip(192, 168, 4, 1); const IPAddress ap_mask(255, 255, 255, 0); ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) - && WiFi.softAP("MeshCore-OTA", nullptr); + && WiFi.softAP("MeshCore-OTA", nullptr, + mesh::wifi::accessPointChannel()); } // A valid IP does not imply a discoverable hotspot. An LR protocol bit // left by ESP-NOW makes AP beacons incompatible with ordinary clients. @@ -398,7 +399,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { const IPAddress ap_ip(192, 168, 4, 1); const IPAddress ap_mask(255, 255, 255, 0); ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) - && WiFi.softAP("MeshCore-OTA", NULL); + && WiFi.softAP("MeshCore-OTA", NULL, + mesh::wifi::accessPointChannel()); } if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) { WiFi.softAPdisconnect(true); diff --git a/test/fixtures/rs232_runtime/json.cpp b/test/fixtures/rs232_runtime/json.cpp new file mode 100644 index 00000000..82f587e4 --- /dev/null +++ b/test/fixtures/rs232_runtime/json.cpp @@ -0,0 +1,93 @@ +// Production JSON parser, bridge schema, and legacy import block are inserted +// by test_rs232_runtime.py. Only file access and save observation are faked. +#include +#include +#include +#include + +struct NodePrefs : ConfigSerializer { + uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0; + uint16_t bridge_delay = 500; + uint8_t bridge_pkt_src = 1, bridge_channel = 1, bridge_format = 0; + uint32_t bridge_baud = 115200; + uint8_t usb_logging_enabled = 0, usb_debug_enabled = 0; + char bridge_secret[16] = "test-secret", node_name[32] = "before import"; + @BRIDGE_SCHEMA@ + BridgePrefs bridge; + NodePrefs() : bridge(this) {} + void structure() override { + @NAME_SCHEMA@ + @BRIDGE_ROOT@ + } +}; +struct File : Stream { + std::string text; + size_t pos = 0; + bool open = true; + explicit File(std::string value) : text(value) {} + explicit operator bool() const { return open; } + int available() override { return static_cast(text.size() - pos); } + int read() override { return pos < text.size() ? text[pos++] : -1; } + int peek() override { return pos < text.size() ? text[pos] : -1; } + void close() { open = false; } +}; +struct FakeFS { + std::string text; + bool exists(const char*) const { return true; } + File open(const char*, const char*) { return File(text); } +}; +struct CLI { + NodePrefs prefs; + NodePrefs* _prefs = &prefs; + int saves = 0; + uint8_t saved_uart = 0, saved_enabled = 0, saved_secondary = 0; + void savePrefs(FakeFS*) { + ++saves; saved_uart = prefs.bridge_uart; + saved_enabled = prefs.bridge_enabled; saved_secondary = prefs.espnow_bridge_enabled; + } + bool import(FakeFS* fs) { + bool loaded = false, is_upgrade = false; + @IMPORT@ + assert(loaded == is_upgrade); + return loaded; + } +}; + +int main() { + for (const char* json : { + "{name:\"imported node\",bridge:{en:1}}", + "{name:\"imported node\",bridge:{en:1,uart:0}}", + "{name:\"imported node\",bridge:{en:1,uart:1}}", + "{name:\"imported node\",bridge:{en:1,uart:2}}", + "{name:\"imported node\",bridge:{en:0,uart:2}}", + }) { + CLI cli; + cli.prefs.espnow_bridge_enabled = 1; // An old unused flag cannot prove intent. + FakeFS fs{json}; + assert(cli.import(&fs) && cli.saves == 1); + assert(strcmp(cli.prefs.node_name, "imported node") == 0); +#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + const bool explicit_uart = strstr(json, "uart:1") || strstr(json, "uart:2"); + const bool enabled = explicit_uart && strstr(json, "en:1"); + assert(cli.prefs.bridge_enabled == enabled && cli.prefs.bridge_uart == 2); +#else + assert(cli.prefs.bridge_enabled == (strstr(json, "en:1") != nullptr)); + assert(cli.prefs.bridge_uart == (strstr(json, "uart:0") ? 0 + : strstr(json, "uart:1") ? 1 : 2)); +#endif +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) + assert(cli.prefs.espnow_bridge_enabled == 0); +#else + assert(cli.prefs.espnow_bridge_enabled == 1); +#endif + assert(cli.saved_uart == cli.prefs.bridge_uart && cli.saved_enabled == cli.prefs.bridge_enabled); + assert(cli.saved_secondary == cli.prefs.espnow_bridge_enabled); + } + // Malformed JSON may partially apply scalar fields before the parser rejects it. + CLI broken; + FakeFS malformed{"{bridge:{en:1,uart:1}"}; + assert(!broken.import(&malformed) && broken.saves == 0); +#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + assert(broken.prefs.bridge_enabled == 0 && broken.prefs.bridge_uart == 2); +#endif +} diff --git a/test/fixtures/rs232_runtime/main.cpp b/test/fixtures/rs232_runtime/main.cpp new file mode 100644 index 00000000..0f099440 --- /dev/null +++ b/test/fixtures/rs232_runtime/main.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include +#include +#include +#include + +#define WITH_BRIDGE 1 +#define WITH_RS232_BRIDGE Serial2 +#define WITH_RS232_BRIDGE_UART 2 +#define WITH_RS232_BRIDGE_RX 16 +#define WITH_RS232_BRIDGE_TX 17 +#define ENV_INCLUDE_GPS 1 +#define BRIDGE_MAX_BAUD 115200 +#define MESH_DEBUG_PRINTLN(...) ((void)0) + +namespace mesh { struct Packet {}; } +struct StrHelper { + static void strncpy(char* out, const char* in, size_t size) { + assert(size != 0); + std::strncpy(out, in, size - 1); out[size - 1] = 0; + } +}; + +template T constrain(T value, int low, int high) { + return value < static_cast(low) ? static_cast(low) + : value > static_cast(high) ? static_cast(high) : value; +} + +struct NodePrefs { + uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0; + uint16_t bridge_delay = 0; + uint8_t bridge_pkt_src = 0, bridge_uart = 0, bridge_channel = 0, bridge_format = 0; + uint32_t bridge_baud = 0; + char bridge_secret[32] = {}; +}; + +struct FakeUart { + unsigned begins = 0, ends = 0; + int rx = -1, tx = -1; + uint32_t baud = 0; + bool running = false; +} Serial2; + +struct FakeSensors { + unsigned yield_calls = 0; + bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1; } + bool gpsSerialTransportMayConflict(uint8_t uart) const { return uart == 1; } + bool gpsSerialTransportCanYield(uint8_t) const { return true; } + bool setGpsSerialTransportBlocked(uint8_t, bool) { ++yield_calls; return true; } +}; + +class AbstractBridge { +public: + virtual ~AbstractBridge() = default; + virtual void begin() = 0; + virtual void end() = 0; + virtual bool isRunning() const = 0; + virtual void loop() = 0; + virtual void sendPacket(mesh::Packet*) = 0; +}; + +class RS232Bridge : public AbstractBridge { + NodePrefs* prefs; + FakeUart& uart; + int rx, tx; + bool running = false; +public: + inline static unsigned live = 0; + inline static unsigned fail_starts = 0; + unsigned loops = 0, sent = 0; + RS232Bridge(NodePrefs* prefs_, FakeUart& uart_, int rx_, int tx_, void*, void*) + : prefs(prefs_), uart(uart_), rx(rx_), tx(tx_) { ++live; } + ~RS232Bridge() override { --live; } + void begin() override { + ++uart.begins; + if (fail_starts != 0) { --fail_starts; return; } + uart.rx = rx; uart.tx = tx; uart.baud = prefs->bridge_baud; + running = uart.running = true; + } + void end() override { ++uart.ends; running = uart.running = false; } + bool isRunning() const override { return running; } + void loop() override { ++loops; } + void sendPacket(mesh::Packet*) override { ++sent; } +}; + +class ESPNowBridge : public AbstractBridge { + bool running = false; +public: + unsigned begins = 0, ends = 0, fail_starts = 0; + unsigned loops = 0, sent = 0; + void begin() override { + ++begins; + if (fail_starts) { --fail_starts; return; } + running = true; + } + void end() override { ++ends; running = false; } + bool isRunning() const override { return running; } + void loop() override { ++loops; } + void sendPacket(mesh::Packet*) override { ++sent; } +}; + +#ifdef WITH_ESPNOW_BRIDGE +static uint32_t fake_millis = 100; +static uint32_t millis() { return fake_millis; } +static bool millisHasNowPassed(uint32_t deadline) { return fake_millis >= deadline; } +#endif +struct FakeBoard { bool ota = false; bool isOTAUpdateRunning() const { return ota; } }; +struct FakeCLI { + FakeBoard board; + FakeBoard* getBoard() { return &board; } +}; + +struct Callbacks { + virtual ~Callbacks() = default; + virtual bool isBridgeRunning() const = 0; + virtual bool setBridgeState(bool) = 0; + virtual bool restartBridge() = 0; +#ifdef WITH_ESPNOW_BRIDGE + virtual bool isEspNowBridgeRunning() = 0; + virtual bool setEspNowBridgeState(bool) = 0; + virtual bool restartEspNowBridge() = 0; +#endif +}; + +class MyMesh : public Callbacks { +public: + NodePrefs _prefs; + RS232Bridge* bridge = nullptr; + uint8_t active_rs232_bridge_uart = 0; + FakeSensors sensors; + FakeCLI _cli; +#ifdef WITH_ESPNOW_BRIDGE + ESPNowBridge espnow_bridge; + uint32_t shared_espnow_retry_at = 0; +#endif + void* _mgr = nullptr; + void* getRTCClock() { return nullptr; } + void configureBridgeFilter(AbstractBridge*) {} + void defaults() { @DEFAULTS@ } + void boot() { @BOOT@ } + void routeRx(mesh::Packet* pkt) { + @ROUTE_RX@ + } + void routeTx(mesh::Packet* pkt) { + @ROUTE_TX@ + } + void serviceBridges() { + @SERVICE@ + } + bool bridgesPreventSleep() const { + @PENDING@ + return false; + } + ~MyMesh() override { + assert(setBridgeState(false)); +#ifdef WITH_ESPNOW_BRIDGE + assert(setEspNowBridgeState(false)); +#endif + } + @LIFECYCLE@ +}; + +struct FakeFile { + bool present; + uint8_t value; + int available() const { return present ? 1 : 0; } + size_t read(uint8_t* out, size_t size) { + assert(size == 1); + if (!present) return 0; + *out = value; present = false; return 1; + } +}; + +@PARSERS@ + +class CommonCLI { +public: + NodePrefs* _prefs; + MyMesh* _callbacks; + FakeSensors* _sensors; + bool _com_prefs_needs_upgrade = false; + unsigned saves = 0; + NodePrefs stored; + explicit CommonCLI(MyMesh& mesh) + : _prefs(&mesh._prefs), _callbacks(&mesh), _sensors(&mesh.sensors) {} + void savePrefs() { ++saves; stored = *_prefs; } + void load(FakeFile file) { + @INITIAL_UART@ + @LOAD_UART@ + } + void set(const char* input, char* reply) { + // Production dispatch receives a fixed-size command buffer. + char config[256] = {}; + assert(strlen(input) < sizeof(config)); + strcpy(config, input); + @SET_BRANCHES@ + } + void get(const char* config, char* reply) { @GET_BRANCHES@ } +}; + +static void expect_get(CommonCLI& cli, const char* key, const char* expected) { + char reply[256] = {}; + cli.get(key, reply); + assert(strcmp(reply, expected) == 0); +} + +static void check_upgrade(bool has_tail, uint8_t saved_uart, uint8_t enabled) { + MyMesh mesh; mesh.defaults(); + mesh._prefs.bridge_enabled = enabled; + CommonCLI cli(mesh); + cli.load(FakeFile{has_tail, saved_uart}); +#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + const bool normal_upgrade = !has_tail || saved_uart == 0; +#else + const bool normal_upgrade = false; +#endif + assert(mesh._prefs.bridge_enabled == (normal_upgrade ? 0 : enabled)); + assert(mesh._prefs.bridge_uart == 2); + if (normal_upgrade || (has_tail && saved_uart != 2)) + assert(cli._com_prefs_needs_upgrade); + const unsigned begins = Serial2.begins; + mesh.boot(); + assert(mesh.isBridgeRunning() == (mesh._prefs.bridge_enabled != 0)); + assert(Serial2.begins == begins + (mesh._prefs.bridge_enabled ? 1 : 0)); + assert(mesh.sensors.yield_calls == 0); +} + +int main() { + { + MyMesh mesh; mesh.defaults(); +#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + assert(mesh._prefs.bridge_enabled == 0); +#else + assert(mesh._prefs.bridge_enabled == 1); +#endif + assert(mesh._prefs.bridge_uart == 2 && mesh._prefs.bridge_baud == 115200); + const unsigned begins = Serial2.begins; + mesh.boot(); + assert(Serial2.begins == begins + (mesh._prefs.bridge_enabled ? 1 : 0)); + } + // Missing old tails and current normal-repeater zero sentinels fail safe. + // Legacy dedicated UART1 is intent, and normalizes to the board's UART2. + for (uint8_t enabled : {uint8_t{0}, uint8_t{1}}) { + check_upgrade(false, 0, enabled); + check_upgrade(true, 0, enabled); + check_upgrade(true, 1, enabled); + check_upgrade(true, 2, enabled); + } + { + MyMesh mesh; mesh.defaults(); mesh._prefs.bridge_enabled = 0; + CommonCLI cli(mesh); + char reply[256] = {}; +#ifdef WITH_ESPNOW_BRIDGE + expect_get(cli, "bridge.type", "> rs232+espnow"); +#else + expect_get(cli, "bridge.type", "> rs232"); +#endif + expect_get(cli, "bridge.enabled", "> off"); + expect_get(cli, "bridge.running", "> off"); + expect_get(cli, "bridge.uart", "> 2"); + const unsigned begins = Serial2.begins; + assert(mesh.setBridgeState(false) && RS232Bridge::live == 0); + cli.set("bridge.baud 57600", reply); + assert(strcmp(reply, "OK") == 0 && cli.saves == 1); + assert(Serial2.begins == begins && RS232Bridge::live == 0); + expect_get(cli, "bridge.baud", "> 57600"); + cli.set("bridge.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && cli.saves == 2); + assert(Serial2.running && RS232Bridge::live == 1); + assert(Serial2.rx == 16 && Serial2.tx == 17 && Serial2.baud == 57600); + assert(mesh.sensors.yield_calls == 0); + mesh::Packet packet; + mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1); + assert(mesh.bridgesPreventSleep()); + expect_get(cli, "bridge.enabled", "> on"); + expect_get(cli, "bridge.running", "> on"); + // Persistence reconstructs the same enabled runtime after reboot. + { + MyMesh restarted; restarted.defaults(); restarted._prefs = cli.stored; + CommonCLI restart_cli(restarted); + restart_cli.load(FakeFile{true, cli.stored.bridge_uart}); + restarted.boot(); + assert(restarted.isBridgeRunning() && restarted._prefs.bridge_baud == 57600); + } + cli.set("bridge.baud 38400", reply); + assert(strcmp(reply, "OK") == 0 && Serial2.baud == 38400); + const unsigned saves = cli.saves; + for (const char* invalid : {"bridge.enabled onjunk", "bridge.uart 1", + "bridge.uart 2junk", "bridge.baud 9599", "bridge.baud 115201", + "bridge.baud 57600junk"}) { + cli.set(invalid, reply); + assert(strncmp(reply, "Error", 5) == 0 && cli.saves == saves); + assert(mesh.isBridgeRunning() && mesh._prefs.bridge_baud == 38400); + assert(mesh._prefs.bridge_uart == 2); + } + RS232Bridge::fail_starts = 1; + cli.set("bridge.baud 19200", reply); + assert(strstr(reply, "baud unchanged") != nullptr && cli.saves == saves); + assert(mesh._prefs.bridge_baud == 38400 && mesh.isBridgeRunning()); + assert(Serial2.baud == 38400 && RS232Bridge::live == 1); + cli.set("bridge.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && !mesh.isBridgeRunning()); + assert(RS232Bridge::live == 0 && !Serial2.running); + const unsigned disabled_saves = cli.saves; + RS232Bridge::fail_starts = 1; + cli.set("bridge.enabled on", reply); + assert(strstr(reply, "setting unchanged") != nullptr); + assert(cli.saves == disabled_saves && mesh._prefs.bridge_enabled == 0); + assert(!mesh.isBridgeRunning() && RS232Bridge::live == 0); + // Repeated runtime switches must release the object and hardware UART. + for (unsigned cycle = 0; cycle < 100; ++cycle) { + cli.set("bridge.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isBridgeRunning()); + cli.set("bridge.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && RS232Bridge::live == 0); + } + expect_get(cli, "bridge.enabled", "> off"); + expect_get(cli, "bridge.running", "> off"); + assert(mesh.sensors.yield_calls == 0); + } +#ifdef WITH_ESPNOW_BRIDGE + { + MyMesh mesh; mesh.defaults(); + CommonCLI cli(mesh); + char reply[256] = {}; + mesh::Packet packet; + assert(mesh._prefs.espnow_bridge_enabled == 0); + assert(!mesh.bridgesPreventSleep()); + expect_get(cli, "espnow.enabled", "> off"); + expect_get(cli, "espnow.running", "> off"); + const unsigned initial_uart_begins = Serial2.begins; + cli.set("espnow.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isEspNowBridgeRunning()); + assert(!mesh.isBridgeRunning() && RS232Bridge::live == 0); + assert(Serial2.begins == initial_uart_begins && mesh.bridgesPreventSleep()); + expect_get(cli, "bridge.running", "> off"); + expect_get(cli, "espnow.enabled", "> on"); + expect_get(cli, "espnow.running", "> on"); + mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges(); + assert(mesh.espnow_bridge.sent == 1 && mesh.espnow_bridge.loops == 1); + cli.set("bridge.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isBridgeRunning()); + assert(mesh.isEspNowBridgeRunning() && RS232Bridge::live == 1); + const unsigned secondary_begins = mesh.espnow_bridge.begins; + mesh.routeRx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == 1 && mesh.espnow_bridge.sent == 2); + assert(mesh.bridge->loops == 1 && mesh.espnow_bridge.loops == 2); + // UART changes and rollback must leave the secondary transport running. + cli.set("bridge.baud 57600", reply); + assert(strcmp(reply, "OK") == 0 && mesh.espnow_bridge.begins == secondary_begins); + RS232Bridge::fail_starts = 1; + cli.set("bridge.baud 38400", reply); + assert(strstr(reply, "baud unchanged") != nullptr && mesh.isBridgeRunning()); + assert(mesh._prefs.bridge_baud == 57600 && mesh.isEspNowBridgeRunning()); + assert(mesh.espnow_bridge.begins == secondary_begins); + const unsigned primary_begins = Serial2.begins; + cli.set("bridge.channel 6", reply); + assert(strcmp(reply, "OK") == 0 && mesh.espnow_bridge.begins == secondary_begins + 1); + cli.set("bridge.secret wired-and-wireless", reply); + assert(strcmp(reply, "OK") == 0 && mesh.espnow_bridge.begins == secondary_begins + 2); + cli.set("bridge.format raw", reply); + assert(strncmp(reply, "OK", 2) == 0 && mesh.espnow_bridge.begins == secondary_begins + 3); + assert(Serial2.begins == primary_begins && mesh.isBridgeRunning()); + expect_get(cli, "bridge.channel", "> 6"); + expect_get(cli, "bridge.secret", "> wired-and-wireless"); + expect_get(cli, "bridge.format", "> raw"); + const unsigned saves = cli.saves; + for (const char* invalid : {"espnow.enabled onjunk", "bridge.channel 0", + "bridge.channel 14", "bridge.channel 6junk", "bridge.secret ", + "bridge.format unsupported"}) { + cli.set(invalid, reply); + assert(strncmp(reply, "Error", 5) == 0 && cli.saves == saves); + assert(mesh.isBridgeRunning() && mesh.isEspNowBridgeRunning()); + } + // TX-source mode feeds both transports once and ignores RX. + mesh._prefs.bridge_pkt_src = 0; + const unsigned primary_sent = mesh.bridge->sent; + const unsigned secondary_sent = mesh.espnow_bridge.sent; + mesh.routeRx(&packet); mesh.routeTx(&packet); + assert(mesh.bridge->sent == primary_sent + 1); + assert(mesh.espnow_bridge.sent == secondary_sent + 1); + cli.set("bridge.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && RS232Bridge::live == 0); + assert(mesh.isEspNowBridgeRunning() && mesh.bridgesPreventSleep()); + mesh.routeTx(&packet); // Secondary-only routing cannot dereference primary. + assert(mesh.espnow_bridge.sent == secondary_sent + 2); + cli.set("bridge.enabled on", reply); + cli.set("espnow.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isBridgeRunning()); + assert(!mesh.isEspNowBridgeRunning() && mesh.bridgesPreventSleep()); + const unsigned secondary_when_off = mesh.espnow_bridge.sent; + mesh.routeTx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1); + assert(mesh.espnow_bridge.sent == secondary_when_off); + // An OTA pause cannot be undone by a command or cooperative retry. + mesh._cli.board.ota = true; + const unsigned before_ota = mesh.espnow_bridge.begins; + cli.set("espnow.enabled on", reply); + assert(strstr(reply, "setting saved") != nullptr); + assert(mesh._prefs.espnow_bridge_enabled == 1 && !mesh.isEspNowBridgeRunning()); + mesh.serviceBridges(); + assert(mesh.espnow_bridge.begins == before_ota && mesh.isBridgeRunning()); + mesh._cli.board.ota = false; + mesh.serviceBridges(); + assert(mesh.isEspNowBridgeRunning() && mesh.espnow_bridge.begins == before_ota + 1); + // Failed ESP-NOW startup is rate-limited, without stopping the UART bridge. + cli.set("espnow.enabled off", reply); + mesh.espnow_bridge.fail_starts = 2; + cli.set("espnow.enabled on", reply); + assert(strstr(reply, "setting saved") != nullptr && !mesh.isEspNowBridgeRunning()); + mesh.serviceBridges(); + const unsigned retry_begins = mesh.espnow_bridge.begins; + mesh.serviceBridges(); + assert(mesh.espnow_bridge.begins == retry_begins && mesh.isBridgeRunning()); + fake_millis += 5001; + mesh.serviceBridges(); + assert(mesh.isEspNowBridgeRunning() && mesh.espnow_bridge.begins == retry_begins + 1); + // Independent switches repeatedly leave the other transport intact. + for (unsigned cycle = 0; cycle < 100; ++cycle) { + cli.set("bridge.enabled off", reply); + assert(mesh.isEspNowBridgeRunning() && RS232Bridge::live == 0); + cli.set("bridge.enabled on", reply); + assert(mesh.isEspNowBridgeRunning() && mesh.isBridgeRunning()); + cli.set("espnow.enabled off", reply); + assert(mesh.isBridgeRunning() && !mesh.isEspNowBridgeRunning()); + cli.set("espnow.enabled on", reply); + assert(mesh.isBridgeRunning() && mesh.isEspNowBridgeRunning()); + } + cli.set("bridge.enabled off", reply); + cli.set("espnow.enabled off", reply); + assert(!mesh.bridgesPreventSleep() && RS232Bridge::live == 0); + assert(mesh.sensors.yield_calls == 0); + } +#endif + assert(RS232Bridge::live == 0); + puts("RS232 runtime checks passed"); +} diff --git a/test/fixtures/rs232_runtime/prefs.cpp b/test/fixtures/rs232_runtime/prefs.cpp new file mode 100644 index 00000000..93bd8095 --- /dev/null +++ b/test/fixtures/rs232_runtime/prefs.cpp @@ -0,0 +1,120 @@ +struct Capture { + std::vector bytes; + std::map offsets; + size_t write(const uint8_t* data, size_t size) { + offsets[data] = bytes.size(); + bytes.insert(bytes.end(), data, data + size); + return size; + } + size_t offset(const void* field) const { return offsets.at(field); } +}; + +int main() { + CommonCLI writer; + strcpy(writer.prefs.node_name, "MKE combined repeater"); + strcpy(writer.prefs.password, "test-password"); + writer.prefs.bridge_uart = 2; + writer.prefs.bridge_baud = 57600; + writer.prefs.bridge_channel = 6; + strcpy(writer.prefs.bridge_secret, "test-secret"); + Capture initial; + assert(writeCommonPrefsImage(initial, &writer.prefs)); + const size_t uart_offset = initial.offset(&writer.prefs.bridge_uart); + const size_t enabled_offset = initial.offset(&writer.prefs.bridge_enabled); + const size_t channel_offset = initial.offset(&writer.prefs.ota_channel); +#ifdef ESPNOW_BRIDGE_MERGED + assert(initial.bytes.size() == 875 && initial.bytes.back() == 0xA1); + const size_t marker_offset = channel_offset + 1; + assert(marker_offset == initial.bytes.size() - 1); +#else + assert(initial.bytes.size() == 874 && channel_offset == initial.bytes.size() - 1); +#endif + assert(initial.offset(writer.prefs.node_name) == 4); + assert(initial.offset(&writer.prefs.freq) == 72); + assert(initial.offset(&writer.prefs.bridge_enabled) == 127); + + for (uint8_t primary : {uint8_t{0}, uint8_t{1}}) { + for (uint8_t secondary : {uint8_t{0}, uint8_t{1}}) { + writer.prefs.bridge_enabled = primary; + writer.prefs.espnow_bridge_enabled = secondary; + writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common); + assert(writer._common_save_succeeded); + const auto image = writer.fs.files.at("/com_prefs"); + Capture checked; + assert(writeCommonPrefsImage(checked, &writer.prefs)); + assert(checked.bytes == image); // Checked and ordinary writers agree. + CommonCLI reader; + reader.fs.files["/com_prefs"] = image; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.bridge_enabled == primary); + assert(reader.prefs.espnow_bridge_enabled == secondary); +#ifdef WITH_RS232_BRIDGE + assert(reader.prefs.bridge_uart == 2); +#else + assert(reader.prefs.bridge_uart == 0); +#endif + assert(reader.prefs.bridge_baud == 57600 && reader.prefs.bridge_channel == 6); + assert(strcmp(reader.prefs.node_name, writer.prefs.node_name) == 0); + assert(strcmp(reader.prefs.bridge_secret, "test-secret") == 0); + } + } + writer.prefs.bridge_enabled = 1; + writer.prefs.espnow_bridge_enabled = 1; + Capture on; + assert(writeCommonPrefsImage(on, &writer.prefs)); + +#ifdef RS232_BRIDGE_MERGED + // Missing UART tails and current no-bridge zero sentinels both fail safe. + for (bool missing_tail : {false, true}) { + auto old = on.bytes; + if (missing_tail) old.resize(uart_offset); + else old[uart_offset] = 0; + assert(old[enabled_offset] == 1); + CommonCLI reader; + reader.fs.files["/com_prefs"] = old; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.bridge_enabled == 0 && reader.prefs.bridge_uart == 2); + assert(reader._com_prefs_needs_upgrade); + } + // Older dedicated MKE builds selected Serial2 but persisted the UART1 fallback. + auto dedicated = on.bytes; + dedicated[uart_offset] = 1; + CommonCLI legacy_reader; + legacy_reader.fs.files["/com_prefs"] = dedicated; + legacy_reader.loadPrefsInt(&legacy_reader.fs, "/com_prefs"); + assert(legacy_reader.prefs.bridge_enabled == 1 && legacy_reader.prefs.bridge_uart == 2); +#endif + +#ifdef ESPNOW_BRIDGE_MERGED + for (unsigned invalid = 0; invalid <= 255; ++invalid) { + if (invalid == 0xA1) continue; + auto old = on.bytes; + old[marker_offset] = static_cast(invalid); + CommonCLI reader; + reader.fs.files["/com_prefs"] = old; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.espnow_bridge_enabled == 0); + assert(reader.prefs.bridge_enabled == 1 && reader._com_prefs_needs_upgrade); + } + // Old 874-byte profiles saved the unused secondary flag as one. It is not intent. + auto old = on.bytes; + old.resize(marker_offset); + CommonCLI reader; + reader.fs.files["/com_prefs"] = old; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.espnow_bridge_enabled == 0 && reader.prefs.bridge_enabled == 1); + reader.fs.files["/com_prefs"] = on.bytes; + reader.fs.fail_read_after = static_cast(marker_offset); + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.espnow_bridge_enabled == 0); + + // A torn append must retain the previous durable settings image. + writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common); + assert(writer._common_save_succeeded); + const auto previous = writer.fs.files.at("/com_prefs"); + writer.fs.fail_write_after = static_cast(marker_offset); + writer.prefs.espnow_bridge_enabled = 0; + assert(!writer.trySavePrefs()); + assert(writer.fs.files.at("/com_prefs") == previous); +#endif +} diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp index 1f1801de..28a977b7 100644 --- a/test/fixtures/wifi_ota_start.cpp +++ b/test/fixtures/wifi_ota_start.cpp @@ -18,6 +18,7 @@ constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; using esp_err_t = int; bool allow_protocol_reset = true; +bool constrain_espnow_channel = true; struct IPAddress { std::string value; IPAddress() = default; @@ -31,6 +32,7 @@ struct IPAddress { struct WiFiFake { bool connected = true, ap = false, allow_ap = true; unsigned starts = 0; + int ap_channel = 0; uint8_t ap_protocol = 15; // Valid driver state, but LR beacons hide the AP from ordinary clients. int status() const { return connected ? WL_CONNECTED : 0; } int getMode() const { return ap ? WIFI_AP : 0; } @@ -41,8 +43,9 @@ struct WiFiFake { assert(mask.value == "255.255.255.0"); return allow_ap; } - bool softAP(const char* ssid, const char* password) { + bool softAP(const char* ssid, const char* password, int channel = 1) { assert(strcmp(ssid, "MeshCore-OTA") == 0 && password == nullptr); + ap_channel = channel; ++starts; ap = allow_ap; return ap; } void softAPdisconnect(bool) { ap = false; } @@ -54,6 +57,8 @@ esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) { return ESP_OK; } namespace mesh { namespace wifi { +bool espNowChannelConstrained() { return constrain_espnow_channel; } +uint8_t activeEspNowChannel() { return 6; } @AP_PROTOCOL_POLICY@ } } static unsigned server_starts = 0; @@ -123,7 +128,10 @@ int main() { + "/update"; assert(strncmp(reply.text, url.c_str(), url.size()) == 0); assert(strstr(reply.text, instruction)); - if (ap || !WiFi.connected) assert(WiFi.ap_protocol == 7); + if (ap || !WiFi.connected) { + assert(WiFi.ap_protocol == 7); + assert(WiFi.ap_channel == (constrain_espnow_channel ? 6 : 1)); + } assert(strlen(reply.text) < 160 && reply.guard == 0x12345678); }; // Normal start reports the actual LAN address and does not disconnect it. @@ -174,6 +182,7 @@ int main() { assert(board.stopOTAUpdate(reply.text)); assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap && WiFi.connected); + constrain_espnow_channel = false; WiFi.connected = false; allow_protocol_reset = false; assert(!board.startOTAUpdate("test node", reply.text, false)); diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index f1135ef5..5551f20b 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -132,6 +132,7 @@ fi # and BLE constraints cannot silently disappear through that inheritance. pio project config --json-output | python3 -c ' import json +import re import sys sections = {section: dict(options) for section, options in json.load(sys.stdin)} @@ -325,6 +326,7 @@ for env_name in ( "solarxiao_33S_repeater", "Heltec_v3_repeater", "Heltec_WSL3_repeater", + "MKE_s3_repeater", "Heltec_t096_repeater", "Heltec_t096_repeater_lora_ota_no_external_sensors", "RAK_4631_repeater", @@ -339,6 +341,55 @@ 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=") +# MKE keeps its ordinary role and UART wiring while exposing the existing +# persistent bridge controls. The merged marker selects the disabled default; +# the explicit legacy bridge remains enabled for deliberate direct builds. +for env_name in ("MKE_s3_repeater", "MKE_s3_repeater_bridge_rs232"): + require(env_name, "build_flags", "WITH_RS232_BRIDGE=Serial2") + require(env_name, "build_flags", "WITH_RS232_BRIDGE_UART=2") + require(env_name, "build_flags", "WITH_RS232_BRIDGE_RX=16") + require(env_name, "build_flags", "WITH_RS232_BRIDGE_TX=17") + require(env_name, "build_src_filter", "helpers/bridges/RS232Bridge.cpp") +reject("MKE_s3_repeater", "build_flags", "RS232_BRIDGE_DEFAULT_ON") +reject("MKE_s3_repeater_bridge_rs232", "build_flags", "RS232_BRIDGE_MERGED") +require("MKE_s3_repeater", "build_flags", "WITH_ESPNOW_BRIDGE=1") +require("MKE_s3_repeater", "build_flags", "ESPNOW_BRIDGE_MERGED=1") +require("MKE_s3_repeater", "build_src_filter", "helpers/bridges/ESPNowBridge.cpp") +reject("MKE_s3_repeater_bridge_espnow", "build_flags", "ESPNOW_BRIDGE_MERGED") +for env_name in ( + "MKE_s3", + "MKE_s3_repeater_bridge_espnow", + "MKE_s3_room_server", + "MKE_s3_sensor", + "MKE_s3_terminal_chat", + "MKE_s3_companion_radio_usb", + "MKE_s3_companion_radio_ble", + "MKE_s3_companion_radio_wifi", +): + reject(env_name, "build_flags", "WITH_RS232_BRIDGE") + reject(env_name, "build_src_filter", "helpers/bridges/RS232Bridge.cpp") +require("MKE_s3_repeater_bridge_espnow", "build_flags", "WITH_ESPNOW_BRIDGE=1") +require("MKE_s3_repeater_bridge_espnow", "build_src_filter", "helpers/bridges/ESPNowBridge.cpp") + +# GPIO5 is the radio reset line. Guard the complete board wiring rather than +# accepting a bridge pin assignment that compiles but disables a peripheral. +mke_pins = { + name: int(value) + for name, value in re.findall( + r"(?:^|\s)-D\s*([A-Z0-9_]+)=([0-9]+)(?=\s|$)", + option_text("MKE_s3_repeater", "build_flags"), + ) +} +bridge_pins = {mke_pins["WITH_RS232_BRIDGE_RX"], mke_pins["WITH_RS232_BRIDGE_TX"]} +for pin_name in ( + "P_LORA_NSS", "P_LORA_MOSI", "P_LORA_MISO", "P_LORA_SCLK", + "P_LORA_BUSY", "P_LORA_RESET", "P_LORA_DIO_1", + "PIN_BOARD_SDA", "PIN_BOARD_SCL", "PIN_USER_BTN", + "PIN_GPS_RX", "PIN_GPS_TX", "PIN_GPS_EN", +): + if mke_pins[pin_name] in bridge_pins: + raise SystemExit(f"test_build_profiles: MKE bridge UART conflicts with {pin_name}") + for env_name in ( "t1000e_companion_radio_usb", "MeshTracker_X1_companion_radio_usb", @@ -918,6 +969,39 @@ apply_esp32_full_shared_bridge_profile Station_G2_repeater_observer_mqtt [[ "$(get_unified_full_infrastructure_target Station_G2_repeater_bridge_espnow)" \ = "Station_G2_repeater_observer_mqtt" ]] \ || fail "Station G2 Full ESP-NOW target did not resolve to the combined MQTT recipe" + +# Heltec V4 TFT has no combined observer recipe, so its dedicated ESP-NOW +# image must survive release selection in either input order. MKE now owns +# both bridges directly and is checked against its canonical alias below. +for plain_target in heltec_v4_tft_repeater; do + espnow_target=${plain_target}_bridge_espnow + if get_unified_full_infrastructure_target "$espnow_target" >/dev/null; then + fail "$espnow_target unexpectedly acquired a combined observer recipe" + fi + [ "$(select_ordinary_full_targets "$plain_target" "$espnow_target")" \ + = "$(printf "%s\n" "$plain_target" "$espnow_target")" ] \ + || fail "$plain_target incorrectly replaced its dedicated ESP-NOW image" + [ "$(select_ordinary_full_targets "$espnow_target" "$plain_target")" \ + = "$(printf "%s\n" "$espnow_target" "$plain_target")" ] \ + || fail "$plain_target lost its dedicated ESP-NOW image with reversed inputs" +done +[ "$(get_unified_full_infrastructure_target Heltec_v3_repeater_bridge_espnow)" \ + = Heltec_v3_repeater_observer_mqtt ] \ + || fail "Heltec V3 lost its same-board combined observer recipe" +[ "$(select_ordinary_full_targets Heltec_v3_repeater \ + Heltec_v3_repeater_bridge_espnow Heltec_v3_repeater_observer_mqtt)" \ + = Heltec_v3_repeater ] \ + || fail "Heltec V3 retained duplicate ESP-NOW/observer Full images" +[ "$(select_ordinary_full_targets MKE_s3_repeater MKE_s3_repeater_bridge_espnow)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 retained a duplicate ESP-NOW image after merging its sources" +[ "$(select_ordinary_full_targets MKE_s3_repeater_bridge_espnow MKE_s3_repeater)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 bridge input order changed its canonical Full image" +[ "$(select_ordinary_full_targets LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow \ + LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_)" \ + = LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_ ] \ + || fail "TLora trailing-underscore observer failed to replace its ESP-NOW sibling" ESP32_FULL_BUILD=0 PLATFORMIO_BUILD_FLAGS="" PLATFORMIO_BUILD_SRC_FILTER="" @@ -1380,6 +1464,73 @@ fi || 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" +[ "$(get_merged_rs232_repeater_replacement MKE_s3_repeater_bridge_rs232)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 RS232 bridge did not map to its merged repeater" +is_supported_build_env MKE_s3_repeater_bridge_rs232 \ + || fail "MKE S3 legacy RS232 target stopped being directly buildable" +[ "$(get_pio_build_env MKE_s3_repeater_bridge_rs232)" \ + = MKE_s3_repeater_bridge_rs232 ] \ + || fail "MKE S3 direct legacy build lost its bridge-on recipe" +is_redundant_bulk_build_target MKE_s3_repeater_bridge_rs232 \ + || fail "MKE S3 dedicated RS232 image remained in canonical bulk builds" +if is_redundant_bulk_build_target MKE_s3_repeater; then + fail "MKE S3 canonical repeater was omitted" +fi +is_redundant_bulk_build_target MKE_s3_repeater_bridge_espnow \ + || fail "MKE S3 dedicated ESP-NOW image remained beside its combined repeater" +[ "$(get_merged_espnow_repeater_replacement MKE_s3_repeater_bridge_espnow)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 ESP-NOW bridge did not map to its merged repeater" +is_supported_build_env MKE_s3_repeater_bridge_espnow \ + || fail "MKE S3 legacy ESP-NOW target stopped being directly buildable" +[ "$(get_pio_build_env MKE_s3_repeater_bridge_espnow)" \ + = MKE_s3_repeater_bridge_espnow ] \ + || fail "MKE S3 direct legacy ESP-NOW build lost its bridge-on recipe" +( + SUPPORTED_PIO_ENVS=( + MKE_s3_repeater + MKE_s3_repeater_bridge_rs232 + MKE_s3_repeater_bridge_espnow + ) + [ "$(resolve_all_firmwares)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 bulk inventory did not consolidate both bridge images" + [ "$(resolve_repeater_firmwares)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 repeater inventory retained dedicated bridge images" + [ "$(resolve_full_esp32_firmwares)" \ + = MKE_s3_repeater ] \ + || fail "MKE S3 Full ESP32 inventory retained dedicated bridge images" +) + +# A hardcoded merged-role recommendation is insufficient if its normal +# recipe no longer exists, cannot supply Full, or belongs to another board. +# Isolate these metadata faults so a future observer cannot mask the guard. +for replacement_fault in missing full board; do + ( + normal_target=MKE_s3_repeater + bridge_target=MKE_s3_repeater_bridge_espnow + PIO_ENV_PLATFORM_BY_NAME[$normal_target]=ESP32_PLATFORM + PIO_ENV_FULL_BUILD_BY_NAME[$normal_target]=1 + PIO_ENV_BOARD_BY_NAME[$normal_target]=mke-test-board + PIO_ENV_PLATFORM_BY_NAME[$bridge_target]=ESP32_PLATFORM + PIO_ENV_FULL_BUILD_BY_NAME[$bridge_target]=1 + PIO_ENV_BOARD_BY_NAME[$bridge_target]=mke-test-board + for candidate in "$normal_target" "${normal_target}_mqtt" \ + "${normal_target}_observer_mqtt" "${normal_target}_observer_mqtt_"; do + PIO_ENV_MQTT_BY_NAME[$candidate]=0 + done + case "$replacement_fault" in + missing) unset 'PIO_ENV_PLATFORM_BY_NAME[MKE_s3_repeater]' ;; + full) PIO_ENV_FULL_BUILD_BY_NAME[$normal_target]=0 ;; + board) PIO_ENV_BOARD_BY_NAME[$normal_target]=different-board ;; + esac + [ "$(get_ordinary_full_group_key "$bridge_target")" \ + = 'mke-test-board|mke_s3_repeater_bridge_espnow' ] \ + || fail "MKE S3 $replacement_fault replacement incorrectly suppressed its bridge" + ) +done if is_redundant_bulk_build_target wio-e5-repeater_bridge_rs232; then fail "capacity-constrained Wio-E5 RS232 bridge was incorrectly merged" fi diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 5b6cd949..975cde25 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -85,6 +85,8 @@ class CLISettingsContractTest(unittest.TestCase): 'esp32_mqtt_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'WITH_BRIDGE'], 'rs232_gps': ['WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'ENV_INCLUDE_GPS'], + 'rs232_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', + 'WITH_RS232_BRIDGE', 'WITH_ESPNOW_BRIDGE'], 'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'], 'lr2021': ['USE_LR2021'], } diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py index 1994206d..14798a7f 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -9,8 +9,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) from build_local_release import select_ordinary_full_records -def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM") -> dict: - return {"manifest": {"target": target, "build_profile": profile, "platform": platform}, +def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM", + capabilities: tuple[str, ...] = ()) -> dict: + return {"manifest": {"target": target, "build_profile": profile, "platform": platform, + "verified": True, "capabilities": list(capabilities), + "verification": [{"capability": capability, "present": True, + "source": "linked image", "evidence": capability} + for capability in capabilities]}, "files": []} @@ -58,13 +63,13 @@ class ReleaseFullSelectionTest(unittest.TestCase): inputs = [ record("Heltec_v3_repeater_observer_mqtt"), record("Heltec_v3_repeater_bridge_espnow"), - record("Heltec_v3_repeater"), + record("Heltec_v3_repeater", capabilities=("bridge.espnow",)), record("Heltec_v3_repeater", "standard"), record("Heltec_v3_room_server"), record("Station_G2_repeater"), record("Station_G2_repeater_observer_mqtt"), record("Tbeam_SX1262_repeater_bridge_espnow"), - record("Tbeam_SX1262_repeater_observer_mqtt"), + record("Tbeam_SX1262_repeater_observer_mqtt", capabilities=("bridge.espnow",)), record("LilyGo_TLora_V2_1_1_6_repeater"), record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_"), record("Meshadventurer_sx1262_repeater"), @@ -82,6 +87,97 @@ class ReleaseFullSelectionTest(unittest.TestCase): }) self.assertEqual(len(selected), 8) # standard V3 remains alongside Full V3 + def test_mke_combined_repeater_filters_stale_bridge_identities_without_relabeling(self): + primary = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + legacy = [record("MKE_s3_repeater_bridge_" + bridge, profile) + for bridge in ("rs232", "espnow") for profile in ("full", "standard")] + for inputs in ([primary], legacy + [primary], [primary] + legacy): + with self.subTest(primary_first=inputs[0] is primary): + selected = select_ordinary_full_records(inputs) + self.assertEqual(selected, [primary]) + self.assertIs(selected[0], primary) + self.assertEqual(selected[0]["manifest"]["target"], "MKE_s3_repeater") + + def test_mke_legacy_images_require_a_qualified_combined_primary(self): + incomplete = [] + incomplete.append([]) + incomplete.append([record("MKE_s3_repeater", "standard", + capabilities=("bridge.rs232", "bridge.espnow"))]) + for capability in ("bridge.rs232", "bridge.espnow"): + incomplete.append([record("MKE_s3_repeater", capabilities=(capability,))]) + unqualified = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + unqualified["manifest"]["verified"] = False + incomplete.append([unqualified]) + for capability in ("bridge.rs232", "bridge.espnow"): + missing = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + next(check for check in missing["manifest"]["verification"] + if check["capability"] == capability)["present"] = False + incomplete.append([missing]) + unproven = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + unproven["manifest"]["verification"] = [] + incomplete.append([unproven]) + for bridge in ("rs232", "espnow"): + for normal in incomplete: + with self.subTest(bridge=bridge, normal=normal): + with self.assertRaisesRegex(ValueError, "qualified combined repeater"): + select_ordinary_full_records(normal + [record("MKE_s3_repeater_bridge_" + bridge)]) + + def test_mke_full_primary_requires_verified_bridges_without_legacy_images(self): + incomplete = [record("MKE_s3_repeater", capabilities=capabilities) + for capabilities in ((), ("bridge.rs232",), ("bridge.espnow",))] + unqualified = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + unqualified["manifest"]["verified"] = False + incomplete.append(unqualified) + unproven = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + unproven["manifest"]["verification"] = [] + incomplete.append(unproven) + wrong_platform = record("MKE_s3_repeater", platform="NRF52_PLATFORM", + capabilities=("bridge.rs232", "bridge.espnow")) + incomplete.append(wrong_platform) + for capability in ("bridge.rs232", "bridge.espnow"): + missing = record("MKE_s3_repeater", capabilities=("bridge.rs232", "bridge.espnow")) + next(check for check in missing["manifest"]["verification"] + if check["capability"] == capability)["present"] = False + incomplete.append(missing) + for primary in incomplete: + with self.subTest(primary=primary): + with self.assertRaisesRegex(ValueError, "qualified combined repeater"): + select_ordinary_full_records([primary]) + + def test_mke_full_contract_does_not_apply_to_other_platforms_or_standard_profiles(self): + other_platform = record("RAK_4631_repeater", platform="NRF52_PLATFORM") + other_platform["manifest"].update(verified=None, capabilities=None, verification=None) + standard_mke = record("MKE_s3_repeater", "standard") + inputs = [other_platform, standard_mke] + self.assertEqual(select_ordinary_full_records(inputs), inputs) + + def test_dedicated_espnow_survives_when_plain_image_lacks_the_capability(self): + inputs = [record("heltec_v4_tft_repeater"), + record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",))] + for ordered in (inputs, list(reversed(inputs))): + self.assertEqual(select_ordinary_full_records(ordered), ordered) + + def test_qualified_combined_plain_image_replaces_only_its_matching_espnow_image(self): + primary = record("Heltec_v3_repeater", capabilities=("bridge.espnow",)) + legacy = record("Heltec_v3_repeater_bridge_espnow", capabilities=("bridge.espnow",)) + other_board = record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",)) + for inputs in ([primary, legacy, other_board], [legacy, primary, other_board]): + self.assertEqual(select_ordinary_full_records(inputs), [primary, other_board]) + + def test_combined_observer_with_trailing_underscore_replaces_matching_espnow(self): + observer = record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", + capabilities=("bridge.espnow",)) + legacy = record("LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow", + capabilities=("bridge.espnow",)) + for inputs in ([observer, legacy], [legacy, observer]): + self.assertEqual(select_ordinary_full_records(inputs), [observer]) + + def test_unqualified_plain_image_cannot_replace_dedicated_espnow(self): + primary = record("heltec_v4_tft_repeater", capabilities=("bridge.espnow",)) + primary["manifest"]["verified"] = False + legacy = record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",)) + self.assertEqual(select_ordinary_full_records([primary, legacy]), [primary, legacy]) + if __name__ == "__main__": unittest.main() diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py index bdc63305..9a334fe6 100644 --- a/test/test_nrf52_variant_contracts.py +++ b/test/test_nrf52_variant_contracts.py @@ -432,8 +432,11 @@ class Nrf52VariantContractsTest(unittest.TestCase): ) # Declaration, persisted-tail detection, and migration must share the # same guard. A broader use breaks dedicated default-on bridge builds. - self.assertEqual(cli.count(guard), 3) - self.assertEqual(cli.count("has_runtime_bridge_uart"), 3) + loader_start = cli.index("void CommonCLI::loadPrefsInt(") + loader_end = cli.index("static bool writeCommonPrefsImage(", loader_start) + binary_loader = cli[loader_start:loader_end] + self.assertEqual(binary_loader.count(guard), 3) + self.assertEqual(binary_loader.count("has_runtime_bridge_uart"), 3) def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None: recipe = ( diff --git a/test/test_rs232_runtime.py b/test/test_rs232_runtime.py new file mode 100644 index 00000000..75c4ab70 --- /dev/null +++ b/test/test_rs232_runtime.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Execute production RS-232 defaults, preference migration, and CLI lifecycle.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced +from test_ota_channel_persistence import persistence_harness + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/rs232_runtime/main.cpp" + + +class RS232RuntimeTests(unittest.TestCase): + def test_legacy_json_import_requires_explicit_uart_before_enabling(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") + code = (ROOT / "test/fixtures/rs232_runtime/json.cpp").read_text(encoding="ascii") + code = code.replace("@BRIDGE_SCHEMA@", extract_braced(header, "class BridgePrefs") + ";") + code = code.replace("@NAME_SCHEMA@", 'def("name", node_name, sizeof(node_name));') + self.assertIn('def("name", node_name, sizeof(node_name));', header) + code = code.replace("@BRIDGE_ROOT@", 'def("bridge", bridge);') + self.assertIn('def("bridge", bridge);', header) + code = code.replace("@IMPORT@", extract_braced(source, 'if (fs->exists("/prefs.json"))')) + for profile, macros in ( + ("legacy_dedicated", []), + ("merged_default_on", ["RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]), + ("merged", ["RS232_BRIDGE_MERGED=1"]), + ("merged_composite", ["RS232_BRIDGE_MERGED=1", "ESPNOW_BRIDGE_MERGED=1"]), + ): + with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="rs232-json-") as directory: + work = Path(directory) + (work / "Utils.h").write_text( + '#pragma once\n#include \nnamespace mesh { struct Utils {\n' + 'static void printHex(Stream&, uint8_t*, size_t) {}\n' + 'static bool fromHex(uint8_t*, size_t, const char*) { return true; }\n' + '}; }\n', encoding="ascii") + source_file, binary = work / "main.cpp", work / "test" + source_file.write_text(code, encoding="ascii") + result = subprocess.run([ + compiler, "-std=c++17", "-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *["-D" + macro for macro in macros], "-I" + str(work), + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(source_file), str(ROOT / "src/helpers/ConfigSerializer.cpp"), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_complete_production_preference_images(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + base = persistence_harness() + code = base[:base.index("struct Capture {")] + # The generic constants extraction includes the header's fallback UART1. + # Use the resolved MKE UART2 flag instead, as a real build does. + code = code.replace("#define WITH_RS232_BRIDGE_UART 1", "") + code += (ROOT / "test/fixtures/rs232_runtime/prefs.cpp").read_text(encoding="ascii") + for profile, macros in ( + ("ordinary_unmerged", []), + ("rs232_merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1"]), + ("rs232_espnow_merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1", + "ESPNOW_BRIDGE_MERGED=1"]), + ): + with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="rs232-prefs-") as directory: + work = Path(directory) + transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii") + (work / "ContactFileTransaction.h").write_text(transaction.replace( + '#include "IdentityStore.h"', '#include '), encoding="ascii") + source, binary = work / "main.cpp", work / "test" + source.write_text(code, encoding="ascii") + result = subprocess.run([ + compiler, "-std=c++17", "-DESP32_PLATFORM=1", "-DENABLE_OTA=1", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *["-D" + macro for macro in macros], "-I" + str(work), + "-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"), + str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_production_defaults_upgrade_and_runtime_controls(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii") + implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii") + guard = ( + "#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n" + " && !defined(RS232_BRIDGE_DEFAULT_ON)" + ) + declaration_start = cli.index(guard, cli.index('if (file) {')) + declaration = cli[declaration_start:cli.index("#endif", declaration_start) + len("#endif")] + esp_guard = "#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)" + esp_declaration = cli.index(esp_guard, cli.index('if (file) {')) + declaration += "\n" + cli[esp_declaration:cli.index("#endif", esp_declaration) + len("#endif")] + read_start = cli.index("if (file.available() >= (int)sizeof(_prefs->bridge_uart))") + read_end = cli.index("if (file.available() >= (int)sizeof(_prefs->bridge_format))", read_start) + # The fixture supplies a file positioned at the appended UART field. + uart_read = cli[read_start:read_end] + "}\n" + marker_read_start = cli.index(esp_guard, cli.index("if (file.available() >= (int)sizeof(_prefs->ota_channel))")) + uart_read += cli[marker_read_start:cli.index("#endif", marker_read_start) + len("#endif")] + migrate_start = cli.index("// sanitise bad bridge pref values") + migrate_end = cli.index("if (!mesh::bridge::isValidEspNowFormat", migrate_start) + migration = cli[migrate_start:migrate_end] + defaults_start = implementation.index("// bridge defaults") + defaults_end = implementation.index("StrHelper::strncpy(_prefs.bridge_secret", defaults_start) + defaults = implementation[defaults_start:defaults_end] + initial_start = cli.index("#ifdef WITH_RS232_BRIDGE", cli.index("bool is_fresh_install")) + initial = cli[initial_start:cli.index("#endif", initial_start) + len("#endif")] + boot_source = implementation[implementation.index('mesh::hilStartupTrace("mesh_bridge_begin")'):] + boot = extract_braced(boot_source, "if (_prefs.bridge_enabled) {") + secondary_boot = "if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true);" + self.assertIn(secondary_boot, boot_source) + boot += "\n#ifdef WITH_ESPNOW_BRIDGE\n" + secondary_boot + "\n#endif\n" + lifecycle = "\n".join(extract_braced(header, signature) for signature in ( + "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", + "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", + "bool endRS232Bridge()", "bool isBridgeRunning() const override", + "bool setBridgeState(bool enable) override", "bool restartBridge() override", + )) + lifecycle += "\n#ifdef WITH_ESPNOW_BRIDGE\n" + "\n".join( + extract_braced(header, signature) for signature in ( + "bool startSharedEspNowBridgeIfReady()", "bool isEspNowBridgeRunning() override", + "bool setEspNowBridgeState(bool enable) override", "bool restartEspNowBridge() override", + )) + "\n#endif\n" + routing = {} + for name in ("logRx", "logTx"): + start = implementation.index("void MyMesh::" + name + "(") + start = implementation.index("#ifdef WITH_MQTT_BRIDGE\n", start) + end = implementation.index(" if (_logging)", start) + routing[name] = implementation[start:end] + loop_start = implementation.index("#if defined(WITH_ESPNOW_BRIDGE)", + implementation.index("MyMesh::servicePostMeshLoop()")) + loop_end = implementation.index(" if (next_flood_advert", loop_start) + pending_start = implementation.index("#if defined(WITH_BRIDGE)", + implementation.index("bool MyMesh::hasPendingWork()")) + pending_end = implementation.index(" if (radio_driver.isWatchdogObserving()", pending_start) + # Preprocess complete dispatch methods before extracting branches. + # Their feature guards cross the closing braces between else-if cases. + commands = "\n".join(extract_braced(cli, signature) for signature in ( + "void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd(")) + code = FIXTURE.read_text(encoding="ascii") + for name, value in { + "PARSERS": "\n".join(extract_braced(cli, signature) for signature in ( + "static bool parseOnOffStrict(", "static bool configKeyEquals(")), + "DEFAULTS": defaults, "INITIAL_UART": initial, + "LOAD_UART": declaration + "\n" + uart_read + "\n" + migration, + "LIFECYCLE": lifecycle, "BOOT": boot, + "ROUTE_RX": routing["logRx"], "ROUTE_TX": routing["logTx"], + "SERVICE": implementation[loop_start:loop_end], + "PENDING": implementation[pending_start:pending_end], + }.items(): + code = code.replace("@" + name + "@", value) + for profile, macros in ( + ("merged", ["RS232_BRIDGE_MERGED=1"]), + ("merged_default_on", ["RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]), + ("legacy_dedicated", []), + ("merged_rs232_espnow", ["RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1", + "ESPNOW_BRIDGE_MERGED=1"]), + ): + with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="rs232-runtime-") as directory: + work = Path(directory) + source, binary = work / "main.cpp", work / "test" + processed = subprocess.run([ + compiler, "-E", "-P", "-x", "c++", "-DWITH_BRIDGE=1", + "-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2", + "-DENV_INCLUDE_GPS=1", *["-D" + macro for macro in macros], "-", + ], input=commands, capture_output=True, text=True, check=True).stdout + set_branches = "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (memcmp(config, "bridge.enabled ", 15) == 0)', + 'if (memcmp(config, "bridge.baud ", 12) == 0)', + 'if (memcmp(config, "bridge.uart ", 12) == 0)', + )) + get_branches = "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (configKeyEquals(config, "bridge.type"))', + 'if (configKeyEquals(config, "bridge.enabled"))', + 'if (configKeyEquals(config, "bridge.running"))', + 'if (configKeyEquals(config, "bridge.baud"))', + 'if (configKeyEquals(config, "bridge.uart"))', + )) + if "WITH_ESPNOW_BRIDGE=1" in macros: + set_branches = extract_braced(processed, 'if (memcmp(config, "espnow.enabled ", 15) == 0)') \ + + "\nelse " + set_branches + set_branches += "\nelse " + "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (memcmp(config, "bridge.channel ", 15) == 0)', + 'if (memcmp(config, "bridge.secret ", 14) == 0)', + 'if (memcmp(config, "bridge.format ", 14) == 0)', + )) + get_branches += "\nelse " + "\nelse ".join(extract_braced(processed, signature) for signature in ( + 'if (configKeyEquals(config, "espnow.enabled"))', + 'if (configKeyEquals(config, "espnow.running"))', + 'if (configKeyEquals(config, "bridge.channel"))', + 'if (configKeyEquals(config, "bridge.secret"))', + 'if (configKeyEquals(config, "bridge.format"))', + )) + source.write_text(code.replace("@SET_BRANCHES@", set_branches) + .replace("@GET_BRANCHES@", get_branches), encoding="ascii") + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *["-D" + macro for macro in macros], "-I" + str(ROOT / "src"), + str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("RS232 runtime checks passed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index eb332a08..2e065875 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -72,7 +72,8 @@ struct MockWiFi { configured_ap_ip = ip; return ap_config_ok; } - bool softAP(const char*, const char*) { + bool softAP(const char*, const char*, int channel = 1) { + assert(channel == 1); ++ap_start_calls; if (ap_start_ok) { sdk_initialized = sdk_started = sdk_ap_active = true; @@ -85,6 +86,7 @@ struct MockWiFi { } WiFi; namespace mesh { namespace wifi { int applyAccessPointProtocolMask() { WiFi.ap_protocol = 7; return ESP_OK; } +int accessPointChannel() { return 1; } } } int wifi_stop_calls = 0, wifi_deinit_calls = 0; diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py index b6164e91..7c9daffd 100644 --- a/test/test_wifi_ota_start.py +++ b/test/test_wifi_ota_start.py @@ -39,7 +39,8 @@ class WiFiOtaStartTest(unittest.TestCase): mask = re.search(r"static constexpr uint8_t kAccessPointProtocolMask =.*?;", policy, re.DOTALL).group(0) fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + extract_braced( - policy, "inline esp_err_t applyAccessPointProtocolMask()")) + policy, "inline esp_err_t applyAccessPointProtocolMask()") + "\n" + + extract_braced(policy, "inline int accessPointChannel()")) for name, implementation, flags in ( ("lightweight infrastructure", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), ("lightweight companion", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1", "-DCOMPANION_RADIO_FULL=1"]), @@ -177,6 +178,113 @@ int main() { fixture = (ROOT / "test/fixtures/webconfig_ota_handoff.cpp").read_text() self.compile_and_run(fixture.replace("@METHODS@", methods)) + def test_rs232_espnow_ota_pauses_only_wireless_bridge_and_restores_failures(self): + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + start = cli.index(' } else if (memcmp(command, "start ota", 9)') + end = cli.index(' } else if (memcmp(command, "clock", 5)', start) + fixture = r''' +#include +#include +#include +bool ota = false; +struct Callbacks { + bool web = false, espnow = true, uart = true; + bool allow_pause = true, allow_resume = true; + int pauses = 0, resumes = 0, uart_changes = 0; + bool isWebConfigActive() const { return web; } + bool stopWebConfigForOTA(char*) { web = false; return true; } + bool isEspNowBridgeRunning() const { return espnow; } + bool setEspNowBridgeState(bool enabled) { + if (enabled) { + ++resumes; assert(!ota); + if (!allow_resume) return false; + } else { + ++pauses; + if (!allow_pause) return false; + } + espnow = enabled; return true; + } + bool setBridgeState(bool enabled) { ++uart_changes; uart = enabled; return true; } +} callbacks; +struct Board { + bool allow_start = true, allow_stop = true; + int starts = 0; + bool startOTAUpdate(const char*, char* reply, bool) { + assert(!callbacks.espnow && !callbacks.web); ++starts; + ota = allow_start; + strcpy(reply, allow_start ? "Started: http://192.168.4.1/update" + : "ERR: OTA WiFi failed"); + return allow_start; + } + bool stopOTAUpdate(char* reply) { + if (allow_stop) ota = false; + strcpy(reply, allow_stop ? "OK - OTA stopped" : "ERR: OTA upload active"); + return allow_stop; + } +} board; +struct Prefs { + const char* node_name = "test"; + bool bridge_enabled = true, espnow_bridge_enabled = true; +} prefs; +struct CLI { + Board* _board = &board; + Prefs* _prefs = &prefs; + Callbacks* _callbacks = &callbacks; + void run(const char* command, char* reply) { + if (false) { +@CLI@ + } + } +}; +int main() { + CLI cli; + struct { char text[160] = {}; unsigned guard = 42; } reply; + cli.run("start ota invalid", reply.text); + assert(strstr(reply.text, "usage") && callbacks.pauses == 0 && board.starts == 0); + callbacks.allow_pause = false; + cli.run("start ota", reply.text); + assert(strcmp(reply.text, "ERR: could not pause ESP-NOW for OTA") == 0); + assert(board.starts == 0 && callbacks.espnow && callbacks.uart); + callbacks.allow_pause = true; + callbacks.web = true; + cli.run("start ota ap", reply.text); + assert(ota && !callbacks.espnow && callbacks.uart); + assert(strstr(reply.text, "; ESP-NOW paused") && strstr(reply.text, "; WebConfig stopped")); + assert(prefs.bridge_enabled && prefs.espnow_bridge_enabled); + const int resumed_before_failure = callbacks.resumes; + board.allow_stop = false; + cli.run("stop ota", reply.text); + assert(strcmp(reply.text, "ERR: OTA upload active") == 0); + assert(callbacks.resumes == resumed_before_failure && !callbacks.espnow); + board.allow_stop = true; + cli.run("stop ota", reply.text); + assert(!ota && callbacks.espnow && callbacks.uart); + assert(strstr(reply.text, "; ESP-NOW resumed")); + board.allow_start = false; + cli.run("start ota", reply.text); + assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0 && callbacks.espnow); + callbacks.allow_resume = false; + cli.run("start ota", reply.text); + assert(strcmp(reply.text, "ERR: OTA WiFi failed; ESP-NOW resume failed") == 0); + assert(!callbacks.espnow && callbacks.uart); + cli.run("stop ota", reply.text); + assert(strstr(reply.text, "; ESP-NOW resume failed")); + // A disabled secondary must stay disabled after the upload stops. + prefs.espnow_bridge_enabled = false; + callbacks.allow_resume = true; + board.allow_start = true; + const int resumes = callbacks.resumes; + cli.run("start ota", reply.text); + assert(!strstr(reply.text, "ESP-NOW") && !callbacks.espnow); + cli.run("stop ota", reply.text); + assert(callbacks.resumes == resumes && !callbacks.espnow); + assert(callbacks.uart_changes == 0 && callbacks.uart && reply.guard == 42); +} +''' + self.compile_and_run(fixture.replace("@CLI@", cli[start:end]), + "-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1", + "-DWITH_RS232_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1") + def test_background_bridge_retry_waits_until_ota_stops(self): fixture = r''' #include diff --git a/variants/mke_s3/platformio.ini b/variants/mke_s3/platformio.ini index 98324285..50d3071b 100644 --- a/variants/mke_s3/platformio.ini +++ b/variants/mke_s3/platformio.ini @@ -52,9 +52,20 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + ; Runtime bridge is off until enabled with `set bridge.enabled on`. + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 + ; GPIO5 is the LoRa reset line; keep the bridge on GPIO16/17. + -D WITH_RS232_BRIDGE_RX=16 + -D WITH_RS232_BRIDGE_TX=17 + -D RS232_BRIDGE_MERGED=1 + -D WITH_ESPNOW_BRIDGE=1 + -D ESPNOW_BRIDGE_MERGED=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/simple_repeater> lib_deps = @@ -73,6 +84,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 ; Wire the bridge UART to GPIO16/17; GPIO5 is the LoRa reset line. -D WITH_RS232_BRIDGE_RX=16 -D WITH_RS232_BRIDGE_TX=17