diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d1ef19f2f..cd5944c1b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -120,6 +120,8 @@ jobs: run: | python3 -B test/test_transport_filter_wiring.py -v python3 -B test/test_rs232_runtime.py -v + python3 -B test/test_rs232_mqtt_runtime.py -v + python3 -B test/test_rs232_driver_lifecycle.py -v - name: Verify saved repeat-off trace exception and relay guards run: | @@ -346,8 +348,6 @@ jobs: python3 -B test/test_companion_wifi_session.py -v python3 -B test/test_espnow_radio_lifecycle.py -v python3 -B test/test_espnow_runtime.py -v - # Reports the confirmed HTTP-OTA ownership/stale-server bugs as - # explicit expected failures until the separate firmware fix lands. python3 -B test/test_wifi_ota_lifecycle.py -v - name: Verify message reader buttons, touch targets, and footer layouts diff --git a/build_legacy.sh b/build_legacy.sh index 9c355b312..a0468ea1e 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3398,16 +3398,19 @@ 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" - fi pio_env_name=$(get_pio_build_env "$env_name") if [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ && is_esp32_canonical_full_release_target "$env_name"; then pio_env_name=$(get_exact_identity_full_pio_env "$env_name") fi + if is_repeater_role_target "$env_name" \ + && { pio_env_option_contains "$env_name" build_flags "WITH_RS232_BRIDGE=" \ + || pio_env_option_contains "$pio_env_name" build_flags "WITH_RS232_BRIDGE="; }; then + # A Full observer recipe must retain the UART from its normal repeater. + # Check both the logical and compiled recipes so substituting sources + # cannot silently remove RS-232 while its legacy image is retired. + record_build_expectation "bridge.rs232" "_ZN11RS232Bridge5beginEv" + fi if [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ && supports_esp32_full_shared_espnow "$pio_env_name"; then # A capability flag alone cannot retire a dedicated transport artifact. diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index ecdae4088..58e9ced30 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1600,17 +1600,21 @@ if (profile.role === "repeater") toggle("Repeat mesh traffic", "set repeat", "get repeat"); if (info.rs232 && infrastructure) { const mode = chosen.mode; + // MQTT images keep the historical bridge.enabled ESP-NOW alias. + // Their independently saved UART uses the transport-specific controls. + const uartSetting = info.mqtt ? "rs232.enabled" : "bridge.enabled"; + const uartRunning = info.mqtt ? "rs232.running" : "bridge.running"; section("RS232 bridge" + (mode === "rs232" ? " - selected" : ""), [ - { label: "On", commands: ["set bridge.enabled on", "get bridge.running"] }, - { label: "Off", commands: ["set bridge.enabled off"] }, - { label: "Set baud", commands: ["set bridge.enabled off", "set bridge.baud 115200", "set bridge.enabled on"] }, + { label: "On", commands: ["set " + uartSetting + " on", "get " + uartRunning] }, + { label: "Off", commands: ["set " + uartSetting + " off"] }, + { label: "Set baud", commands: ["set " + uartSetting + " off", "set bridge.baud 115200", "set " + uartSetting + " on"] }, ].sort(function (a, b) { return mode === "standard" ? Number(b.label === "Off") - Number(a.label === "Off") : 0; }), "Use the UART and pin map for this exact board. Canonical GPS-enabled RAK4631 uses UART2; select it with set bridge.uart 2 while the bridge is stopped. UART1 needs a compatible GPS-free image."); } if (info.espnowBridge && infrastructure) { // Combined images need the transport-specific switch: bridge.enabled - // controls the UART when RS-232 is compiled alongside ESP-NOW. Older + // keeps its historical meaning for the primary transport. Older // dedicated ESP-NOW releases retain their original bridge.* commands. const independentEspnow = info.rs232 || info.mqtt; toggle("ESP-NOW bridge", independentEspnow ? "set espnow.enabled" : "set bridge.enabled", diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 7a2a9eb94..f0fbda031 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -204,6 +204,18 @@ available from a canonical image: 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. +- Full Heltec V3, Wireless Stick Lite V3, RAK3112 and LilyGo T-LoRa V2.1-1.6 + MQTT repeaters retain RS-232 alongside MQTT and ESP-NOW. The first three + use UART2 with RX on GPIO5 and TX on GPIO6; T-LoRa uses UART2 with RX on + GPIO34 and TX on GPIO25. Use `set rs232.enabled on|off` and + `get rs232.running` independently of `mqtt.enabled` and `espnow.enabled`. + `bridge.baud` and `bridge.uart` configure the UART. Old MQTT preferences + do not enable this newly added UART; its saved intent needs a validated + UART profile marker. Historical `bridge.enabled` keeps its ESP-NOW meaning + in MQTT images and its UART meaning in non-MQTT images. Full qualification + verifies the linked UART driver even when compiling observer sources for a + normal repeater, and resumed release selection rejects Full images without + that proof. - 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/docs/cli_commands.md b/docs/cli_commands.md index c7443f96e..2e4edc2a9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4745,6 +4745,15 @@ bridge actually started in this boot; they can differ after a hardware conflict, missing credentials, or a transient initialization failure. Normal merged repeater images default to `off`; dedicated bridge images may default to `on`. +Full MQTT repeaters with RS-232 expose three independent transports. Use +`set rs232.enabled on|off` and `get rs232.enabled` for saved UART intent; +`get rs232.running` reports the actual UART state. `mqtt.enabled` and +`espnow.enabled` select the other two transports. In MQTT images the historical +`bridge.enabled` alias controls ESP-NOW; in non-MQTT UART images it controls +RS-232. `bridge.baud` and `bridge.uart` change only UART configuration, without +restarting MQTT or ESP-NOW. The new UART mode starts off when upgrading old +MQTT preferences and retains explicit settings across reboots. + --- #### Add a delay to packets routed through this bridge @@ -4908,7 +4917,9 @@ Requires WiFi connected and the MQTT bridge running. **Parameters:** - `rate`: Integer baud rate from `9600` through the board's compiled `BRIDGE_MAX_BAUD` (commonly `500000`); for example `115200`. Stop the bridge - with `set bridge.enabled off` before changing it, then enable it again. + with `set rs232.enabled off` in a combined MQTT image, or + `set bridge.enabled off` in a non-MQTT UART image, before changing it. + Enable the UART again with the matching switch. **Default:** `115200` @@ -4928,7 +4939,8 @@ Requires WiFi connected and the MQTT bridge running. The setting is persistent and restarts an enabled bridge immediately. Normal repeater artifacts start with `bridge.enabled off`; configure the UART and baud -rate before running `set bridge.enabled on`. On the canonical RAK4631 runtime +rate before running `set bridge.enabled on`. Combined MQTT images use +`rs232.enabled` for that independent UART setting. On the canonical RAK4631 runtime image, UART 1 is reserved even if the bounded boot probe hears no RAK12501. Silence cannot prove that a cold L76K is physically absent, and that module remains powered by the shared WB_IO2/3V3_S rail. Use UART 2. UART 1 requires an diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 4736def5f..a3baa94eb 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -84,6 +84,7 @@ Stopping it first lets you finish configuring both sides before connecting them. | Option | Choices and purpose | | --- | --- | | `espnow.enabled` | `on` starts ESP-NOW; `off` stops it and keeps its saved settings. Independent of MQTT or RS-232. | +| `rs232.enabled` | On combined UART images, starts or stops RS-232 independently of MQTT and ESP-NOW. Use `get rs232.running` to check the live UART. | | `bridge.channel` | The 2.4 GHz channel, 1-13. Match it on both boards. This is separate from the LoRa frequency in `set radio`. | | `bridge.format` | `wrapped` is the default for this two-repeater setup and uses the shared secret. `raw` connects to primary-ESP-NOW nodes such as `Generic_ESPNOW` or `SenseCapIndicator-ESPNow`; it ignores the secret. Both peers must use compatible formats. | | `bridge.secret` | A matching, case-sensitive value of 1-15 characters for `wrapped` mode. It separates bridge groups; it is not strong encryption. MeshCore's own message encryption remains in place. | diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index f72c78fd8..5c1f8f25b 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -207,8 +207,10 @@ both transports. Use `set espnow.enabled on|off` for ESP-NOW and, where supported, `set mqtt.enabled on|off` for MQTT. Newly combined ordinary repeaters start with ESP-NOW off until explicitly enabled, including the first upgrade from unmarked legacy preferences. Later reboots preserve that setting. -On combined RS-232 boards `bridge.enabled` controls the UART independently. Both use -one 2.4 GHz radio, so when both are enabled the ESP-NOW bridge channel must +On combined RS-232 boards without MQTT, `bridge.enabled` controls the UART. +Full Heltec V3, WSL3, RAK3112 and T-LoRa V2.1 MQTT images also retain RS-232; +use `set rs232.enabled on|off` and `get rs232.running` for their independent +UART. MQTT and ESP-NOW use one 2.4 GHz radio, so when both are enabled the ESP-NOW bridge channel must match the connected WiFi access point's fixed channel. ESP-NOW-only mode does not require WiFi credentials. Its runtime `bridge.format` setting chooses the peer protocol: `wrapped` (the diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 50a0b8029..b7c32e989 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -26,7 +26,11 @@ Classic ESP32 additionally retains its existing 8 KiB **static DRAM** check. The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, -MQTT connections, OTA scratch and transient allocations. A 160x80 ST7735 +MQTT connections, OTA scratch and transient allocations. ESP32 images with +RS-232 reserve another 8 KiB even when UART defaults off: 4 KiB for the +source-bounded bridge object and 4 KiB for the UART driver and buffers. This +keeps runtime UART enablement in the budget beside MQTT and ESP-NOW. +A 160x80 ST7735 framebuffer needs 25,602 bytes; an OLED allowance is 4 KiB. nRF52 Full with that color framebuffer must have at least 72 KiB available before startup allocations. Headless and OLED devices use their own smaller totals. The JSON lists each diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index 93d61b60e..b2f3f73b6 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -190,6 +190,8 @@ from some portable builds. The USB browser console still works without it. | Verbose USB diagnostics | `set usb.debug on` while USB logging is on | `set usb.debug off` | `get usb.debug` reports saved intent | | Capture RX log to node storage | `log start` | `log stop` | `log` prints the capture locally | | RS232 / ESP-NOW bridge master | `set bridge.enabled on` | `set bridge.enabled off` | `get bridge.enabled`, `get bridge.running`, `get bridge.type` | +| Independent RS232 in combined Full MQTT images | `set rs232.enabled on` | `set rs232.enabled off` | `get rs232.enabled`, `get rs232.running` | +| Independent ESP-NOW in combined Full images | `set espnow.enabled on` | `set espnow.enabled off` | `get espnow.enabled`, `get espnow.running` | | MQTT periodic status publication | `set mqtt.status on` | `set mqtt.status off` | `get mqtt.status` shows connection status | | MQTT packet publication | `set mqtt.packets on` | `set mqtt.packets off` | `get mqtt.packets` | | SNMP on supported MQTT infrastructure | `set snmp on`, then `reboot` | `set snmp off`, then `reboot` | `get snmp` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ccd1dfefd..decc12f6a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -721,7 +721,7 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender memcpy(&reply_data[4], &now, 4); // include our clock (for easy clock sync, and packet hash uniqueness) reply_data[8] = 0; // features #ifdef WITH_RS232_BRIDGE - if (isBridgeRunning()) reply_data[8] |= 0x01; // is bridge, type UART + if (isRs232BridgeRunning()) 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 @@ -1198,6 +1198,12 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { active_bridge->sendPacket(pkt); } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) + // UART output remains independent of MQTT's RX/TX and enabled settings. + if (_prefs.bridge_pkt_src == 1 && isRs232BridgeRunning()) { + 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. @@ -1250,6 +1256,11 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { active_bridge->sendPacket(pkt); } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) + if (_prefs.bridge_pkt_src == 0 && isRs232BridgeRunning()) { + 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()) { @@ -3464,13 +3475,14 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc , espnow_bridge(&_prefs, _mgr, &rtc) , shared_espnow_retry_at(0) #endif -#elif defined(WITH_RS232_BRIDGE) +#endif +#if defined(WITH_RS232_BRIDGE) , bridge(nullptr) - #if defined(WITH_ESPNOW_BRIDGE) + #if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) , espnow_bridge(&_prefs, _mgr, &rtc) , shared_espnow_retry_at(0) #endif -#elif defined(WITH_ESPNOW_BRIDGE) +#elif defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) , bridge(&_prefs, _mgr, &rtc) , shared_espnow_retry_at(0) #endif @@ -3655,11 +3667,17 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // bridge defaults #if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ - && !defined(RS232_BRIDGE_DEFAULT_ON) + && !defined(RS232_BRIDGE_DEFAULT_ON) && !defined(WITH_MQTT_BRIDGE) _prefs.bridge_enabled = 0; // normal repeater until explicitly enabled #else _prefs.bridge_enabled = 1; // enabled #endif +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) + _prefs.rs232_bridge_enabled = 0; +#elif defined(WITH_RS232_BRIDGE) + _prefs.rs232_bridge_enabled = 1; +#endif #if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) _prefs.espnow_bridge_enabled = 0; // merged normal repeater until explicitly enabled #else @@ -3828,7 +3846,7 @@ void MyMesh::begin(FILESYSTEM *fs) { mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); #endif -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) if (!bridge) { bridge = createRS232Bridge(); } @@ -3837,7 +3855,7 @@ void MyMesh::begin(FILESYSTEM *fs) { // partial object/GPS ownership. Advertisements and bridge.running report // the actual stopped state rather than the saved preference. MESH_DEBUG_PRINTLN("RS232 bridge configured on but failed to start"); - if (!setBridgeState(false)) { + if (!setRs232BridgeState(false)) { MESH_DEBUG_PRINTLN( "RS232 bridge cleanup failed; UART/GPS ownership remains tracked"); } @@ -3883,6 +3901,14 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) + if (rs232BridgeEnabled() && !setRs232BridgeState(true)) { + MESH_DEBUG_PRINTLN("RS232 bridge configured on but failed to start"); + if (!setRs232BridgeState(false)) { + MESH_DEBUG_PRINTLN("RS232 bridge cleanup failed; UART/GPS ownership remains tracked"); + } + } +#endif #if defined(WITH_ESPNOW_BRIDGE) \ && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true); @@ -12845,9 +12871,10 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { else startSharedEspNowBridgeIfReady(); #endif #endif -#if defined(WITH_RS232_BRIDGE) \ - || (defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) \ - && !defined(WITH_ESPNOW_BRIDGE)) +#if defined(WITH_RS232_BRIDGE) + if (isRs232BridgeRunning()) bridge->loop(); +#elif 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 @@ -13515,6 +13542,9 @@ 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_RS232_BRIDGE) + if (isRs232BridgeRunning()) return true; +#endif #if defined(WITH_ESPNOW_BRIDGE) \ && (defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)) if (espnow_bridge.isRunning()) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f1e4b89b0..e4a6efbc0 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #if defined(ENABLE_OTA) #include @@ -583,14 +584,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks ESPNowBridge espnow_bridge; uint32_t shared_espnow_retry_at = 0; #endif -#elif defined(WITH_RS232_BRIDGE) +#endif +#if defined(WITH_RS232_BRIDGE) RS232Bridge* bridge; uint8_t active_rs232_bridge_uart = 0; - #if defined(WITH_ESPNOW_BRIDGE) + #if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) ESPNowBridge espnow_bridge; uint32_t shared_espnow_retry_at = 0; #endif -#elif defined(WITH_ESPNOW_BRIDGE) +#elif defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) ESPNowBridge bridge; uint32_t shared_espnow_retry_at = 0; #endif @@ -1207,12 +1209,12 @@ public: RS232Bridge* createRS232Bridge() { #ifdef WITH_RS232_BRIDGE_ALT if (_prefs.bridge_uart == WITH_RS232_BRIDGE_ALT_UART) { - return new RS232Bridge(&_prefs, WITH_RS232_BRIDGE_ALT, + return new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE_ALT, WITH_RS232_BRIDGE_ALT_RX, WITH_RS232_BRIDGE_ALT_TX, _mgr, getRTCClock()); } #endif - return new RS232Bridge(&_prefs, WITH_RS232_BRIDGE, + return new (std::nothrow) RS232Bridge(&_prefs, WITH_RS232_BRIDGE, WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX, _mgr, getRTCClock()); } @@ -1278,11 +1280,55 @@ public: if (stopped && gps_released) active_rs232_bridge_uart = 0; return stopped && gps_released; } + + bool rs232BridgeEnabled() const { +#ifdef WITH_MQTT_BRIDGE + return _prefs.rs232_bridge_enabled != 0; +#else + return _prefs.bridge_enabled != 0; +#endif + } + + bool isRs232BridgeRunning() const override { + return bridge && bridge->isRunning(); + } + + bool setRs232BridgeState(bool enable) override { + if (!enable) { + const bool stopped = endRS232Bridge(); + if (stopped) { + delete bridge; + bridge = nullptr; + } + return stopped; + } + if (isRs232BridgeRunning()) return true; + // Retry any tracked GPS release before constructing a new UART owner. + if (active_rs232_bridge_uart != 0 && !endRS232Bridge()) return false; + if (!bridge) bridge = createRS232Bridge(); + return bridge && beginRS232Bridge(); + } + + bool restartRs232Bridge() override { + // Apply UART changes synchronously so a failed CLI change can restore + // the previous pins/baud without disturbing MQTT or ESP-NOW. + if (!endRS232Bridge()) return false; + delete bridge; + bridge = nullptr; + return !rs232BridgeEnabled() || setRs232BridgeState(true); + } #endif bool isBridgeRunning() const override { -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - return (mqtt_bridge && mqtt_bridge->isRunning()) || espnow_bridge.isRunning(); +#if defined(WITH_MQTT_BRIDGE) + return (mqtt_bridge && mqtt_bridge->isRunning()) +#ifdef WITH_ESPNOW_BRIDGE + || espnow_bridge.isRunning() +#endif +#ifdef WITH_RS232_BRIDGE + || isRs232BridgeRunning() +#endif + ; #else const AbstractBridge* active_bridge = activeBridge(); return active_bridge != nullptr && active_bridge->isRunning(); @@ -1385,7 +1431,11 @@ public: bool setBridgeState(bool enable) override { #if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - if (!enable) return setEspNowBridgeState(false) && setMqttBridgeState(false); + if (!enable) { + const bool espnow_stopped = setEspNowBridgeState(false); + const bool mqtt_stopped = setMqttBridgeState(false); + return espnow_stopped && mqtt_stopped; + } const bool mqtt_ok = _prefs.bridge_enabled ? setMqttBridgeState(true) : setMqttBridgeState(false); const bool espnow_ok = _prefs.espnow_bridge_enabled @@ -1417,7 +1467,7 @@ public: if (!mqtt_bridge) return false; } #endif -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) if (enable && !bridge) { bridge = createRS232Bridge(); if (!bridge) return false; @@ -1449,7 +1499,7 @@ public: mqtt_bridge->setBuildDate(getBuildDate()); mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #endif -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) const bool started = beginRS232Bridge(); #else configureBridgeFilter(active_bridge); @@ -1463,7 +1513,7 @@ public: } else { -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) const bool stopped = endRS232Bridge(); delete bridge; bridge = nullptr; @@ -1487,13 +1537,15 @@ public: return true; } #endif - return restartEspNowBridge() && restartMqttBridge(); + const bool mqtt_ok = restartMqttBridge(); + const bool espnow_ok = restartEspNowBridge(); + return mqtt_ok && espnow_ok; #else #if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_RS232_BRIDGE) if (_cli.getBoard()->isOTAUpdateRunning()) return false; shared_espnow_retry_at = 0; #endif -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) // RS-232 changes must be applied synchronously so the CLI can commit or // roll back the selected pins/baud. This branch also reconstructs a bridge // after a prior allocation/start failure and is not WebConfig-coalesced. diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index 8075588f6..e9567ba8a 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -139,6 +139,26 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: and check.get("present") is True for check in manifest.get("verification") or [])) + def proven_rs232(manifest: dict) -> bool: + return (qualified_full(manifest) + and "bridge.rs232" in (manifest.get("capabilities") or []) + and any(check.get("capability") == "bridge.rs232" + and check.get("present") is True + for check in manifest.get("verification") or [])) + + def role_base(target: str) -> str: + base = target.rstrip("_").lower() + if base.endswith("_observer_mqtt"): + base = base[:-len("_observer_mqtt")] + return base + + # These normal repeaters already supply a UART. Full MQTT source + # substitution must never remove it while retiring the dedicated image. + uart_full_targets = {"heltec_v3_repeater", "heltec_wsl3_repeater", + "rak_3112_repeater", "lilygo_tlora_v2_1_1_6_repeater"} + combined_rs232 = {role_base(record["manifest"]["target"]) for record in records + if proven_rs232(record["manifest"])} & uart_full_targets + # 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. @@ -186,6 +206,10 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: # Resume may contain portable legacy images as well as Full ones. # Retire them only after the matching combined driver is proven. continue + if (manifest["platform"] == "ESP32_PLATFORM" + and legacy_base.endswith("_repeater_bridge_rs232") + and legacy_base[:-len("_bridge_rs232")] in combined_rs232): + continue if manifest["platform"] != "ESP32_PLATFORM" or manifest["build_profile"] != "full": passthrough.append(record) continue @@ -221,10 +245,18 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: # A stale normal image must not displace the proven transport # while simultaneously suppressing its historical dedicated image. priority = -1 + if key in combined_rs232 and not proven_rs232(manifest): + priority = -1 old = chosen.get(key) if old is None or priority > old[0]: chosen[key] = (priority, record) - return passthrough + [item[1] for item in chosen.values()] + selected = [item[1] for item in chosen.values()] + for record in selected: + manifest = record["manifest"] + if role_base(manifest["target"]) in uart_full_targets and not proven_rs232(manifest): + raise ValueError(manifest["target"] + ": Full release requires the verified " + "RS232 bridge from its normal repeater") + return passthrough + selected def stage_release(work: Path, migration_work: Path, destination: Path, version: str, diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 9b74d0a93..e03c83971 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -74,6 +74,11 @@ def requirements(platform, defines, target): parts["wireless_stacks"] = max(wifi, ble) if "COMPANION_EXCLUSIVE_WIFI_BLE" in defines else wifi + ble if "WITH_MQTT_BRIDGE" in defines: parts["mqtt_connections_buffers"] = 24576 + if "WITH_RS232_BRIDGE" in defines: + # The UART bridge is allocated on enable, alongside WiFi/MQTT. + # RS232Bridge.cpp bounds its object to 4 KiB; reserve another + # 4 KiB for the SDK UART task, buffers and allocator overhead. + parts["uart_bridge_and_driver"] = 8192 if "ENABLE_OTA" in defines: parts["ota_source_scratch"] = 8192 if ("WEBCONFIG_DISABLED" not in defines diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 79f9d8987..e615a5856 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -844,7 +844,15 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { 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; +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled = 0; +#else + if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART +#ifdef WITH_RS232_BRIDGE_ALT + && _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART +#endif + ) _prefs->bridge_enabled = 0; +#endif if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART #ifdef WITH_RS232_BRIDGE_ALT && _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART @@ -1014,6 +1022,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->trace_when_repeat_off = 0; _prefs->gps_sync_interval_hours = 0; _prefs->ota_channel = 0; + _prefs->rs232_bridge_enabled = 0; #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); #else @@ -1024,9 +1033,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { #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) +#ifdef WITH_RS232_BRIDGE + bool has_runtime_rs232_intent = false; +#ifndef WITH_MQTT_BRIDGE bool has_runtime_bridge_uart = false; + bool has_rs232_intent_tail = false; +#endif #endif // Every supported layout contains the fixed 290-byte common core. Reject // a truncated in-place write before it can leave strings unterminated or @@ -1392,8 +1404,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->bridge_uart)) { file.read((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); -#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ - && !defined(RS232_BRIDGE_DEFAULT_ON) +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) has_runtime_bridge_uart = true; #endif if (file.available() >= (int)sizeof(_prefs->bridge_format)) { @@ -1422,12 +1433,28 @@ 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) + // Reserve the profile byte in every current writer so + // the appended UART intent never depends on build flags. uint8_t bridge_profile = 0; if (file.read(&bridge_profile, sizeof(bridge_profile)) == sizeof(bridge_profile)) { +#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) has_runtime_espnow_intent = bridge_profile == 0xA1; - } #endif +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) + has_rs232_intent_tail = file.available() > 0; +#endif + if (file.available() >= 2) { + uint8_t rs232_intent = 0; + uint8_t rs232_profile = 0; + file.read(&rs232_intent, sizeof(rs232_intent)); + file.read(&rs232_profile, sizeof(rs232_profile)); + _prefs->rs232_bridge_enabled = rs232_intent; +#ifdef WITH_RS232_BRIDGE + has_runtime_rs232_intent = rs232_profile == 0xB1 + && rs232_intent <= 1; +#endif + } + } } } } @@ -1504,19 +1531,51 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _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 || _prefs->bridge_uart == 0) { - // Pre-merge normal repeaters persisted bridge_enabled=1 even though no - // 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; +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) + const bool legacy_uart_intent = has_runtime_bridge_uart + && (_prefs->bridge_uart == WITH_RS232_BRIDGE_UART +#ifdef WITH_RS232_BRIDGE_ALT + || _prefs->bridge_uart == WITH_RS232_BRIDGE_ALT_UART +#endif + ); +#endif +#ifdef WITH_RS232_BRIDGE + if (!has_runtime_rs232_intent) { + // Old MQTT/normal images had no UART or stored the no-bridge zero + // sentinel. A standalone UART profile can retain supported legacy + // intent; a torn/corrupt new intent never takes that fallback path. +#ifdef WITH_MQTT_BRIDGE + // A nonzero old UART byte alone cannot identify the previous role. + // Preserve MQTT's primary intent and require the independent marker. + _prefs->rs232_bridge_enabled = 0; +#else + _prefs->rs232_bridge_enabled = !has_rs232_intent_tail && legacy_uart_intent + ? (_prefs->bridge_enabled == 1 ? 1 : 0) : 0; + if (has_rs232_intent_tail) { + // A partial/corrupt new UART tail cannot inherit another profile's + // primary MQTT flag, including in dedicated/default-on UART images. + _prefs->bridge_enabled = 0; + } +#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + _prefs->bridge_enabled = _prefs->rs232_bridge_enabled; +#endif +#endif _com_prefs_needs_upgrade = true; } +#ifndef WITH_MQTT_BRIDGE + // A current combined-profile file may carry MQTT on and UART off. Its + // validated UART byte must win before restoring the legacy primary alias. + if (has_runtime_rs232_intent) { + _prefs->bridge_enabled = _prefs->rs232_bridge_enabled; + } + _prefs->rs232_bridge_enabled = _prefs->bridge_enabled == 1 ? 1 : 0; +#endif +#else + _prefs->rs232_bridge_enabled = 0; #endif _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); _prefs->espnow_bridge_enabled = constrain(_prefs->espnow_bridge_enabled, 0, 1); + _prefs->rs232_bridge_enabled = constrain(_prefs->rs232_bridge_enabled, 0, 1); #if defined(WITH_ESPNOW_BRIDGE) && defined(ESPNOW_BRIDGE_MERGED) \ && !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE) // An unrelated legacy primary bridge flag cannot enable this new mode. @@ -1785,8 +1844,17 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { 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 +#else + const uint8_t bridge_profile = 0; #endif + WRITE_COMMON_PREFS(&bridge_profile); // ESP-NOW profile marker/reserved byte + WRITE_COMMON_PREFS(&prefs->rs232_bridge_enabled); // independent UART intent +#ifdef WITH_RS232_BRIDGE + const uint8_t rs232_profile = 0xB1; +#else + const uint8_t rs232_profile = 0; +#endif + WRITE_COMMON_PREFS(&rs232_profile); // UART intent belongs to this profile #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1801,6 +1869,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _common_save_succeeded = false; _prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0; _prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0; +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) + _prefs->rs232_bridge_enabled = _prefs->bridge_enabled == 1 ? 1 : 0; +#elif !defined(WITH_RS232_BRIDGE) + _prefs->rs232_bridge_enabled = 0; +#endif } #ifdef WITH_MQTT_BRIDGE // Observer builds use a verified temp/backup transaction for common prefs. @@ -1952,8 +2025,17 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { 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 +#else + const uint8_t bridge_profile = 0; #endif + file.write(&bridge_profile, sizeof(bridge_profile)); // ESP-NOW profile marker/reserved byte + file.write((uint8_t *)&_prefs->rs232_bridge_enabled, sizeof(_prefs->rs232_bridge_enabled)); +#ifdef WITH_RS232_BRIDGE + const uint8_t rs232_profile = 0xB1; +#else + const uint8_t rs232_profile = 0; +#endif + file.write(&rs232_profile, sizeof(rs232_profile)); // UART intent belongs to this profile _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -3126,7 +3208,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } #ifdef WITH_RS232_BRIDGE if (is_gps_toggle && strcmp(value, "1") == 0 - && _callbacks->isBridgeRunning() + && _callbacks->isRs232BridgeRunning() && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { strcpy(reply, "saved; UART GPS paused while bridge is enabled"); } else @@ -3170,11 +3252,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _prefs->gps_enabled = 1; savePrefs(); - if (_callbacks->isBridgeRunning() + if ( #ifdef WITH_RS232_BRIDGE + _callbacks->isRs232BridgeRunning() && _sensors->gpsUsesSerialUart(_prefs->bridge_uart) #else - && false + false #endif ) { strcpy(reply, "saved; UART GPS paused while bridge is enabled"); @@ -3199,7 +3282,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (!_prefs->gps_enabled) { strcpy(reply, "gps is off"); #ifdef WITH_RS232_BRIDGE - } else if (_callbacks->isBridgeRunning() + } else if (_callbacks->isRs232BridgeRunning() && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { strcpy(reply, "gps paused by RS232 bridge"); #endif @@ -3781,6 +3864,45 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } return; } +#endif +#ifdef WITH_RS232_BRIDGE + if (strncmp(config, "rs232.enabled ", 14) == 0) { + bool enable = false; + if (!parseOnOffStrict(config + 14, enable)) { + strcpy(reply, "Error: use set rs232.enabled on|off"); + return; + } + if (enable && _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart) + && (!_sensors->gpsUsesSerialUart(_prefs->bridge_uart) + || !_sensors->gpsSerialTransportCanYield(_prefs->bridge_uart))) { + strcpy(reply, "Error: UART may be driven by GPS; use UART 2 or a no-GPS build"); + return; + } +#ifdef WITH_MQTT_BRIDGE + uint8_t& intent = _prefs->rs232_bridge_enabled; +#else + uint8_t& intent = _prefs->bridge_enabled; +#endif + const uint8_t previous_enabled = intent; + const bool previous_running = _callbacks->isRs232BridgeRunning(); + intent = enable ? 1 : 0; + const bool applied = _callbacks->setRs232BridgeState(enable); + const bool saved = applied && trySavePrefs(); + if (!saved) { + intent = previous_enabled; +#ifndef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled = _prefs->bridge_enabled == 1 ? 1 : 0; +#endif + const bool restored = _callbacks->setRs232BridgeState(previous_running); + strcpy(reply, restored + ? "Error: RS232 change failed; setting unchanged" + : "Error: RS232 change failed; previous runtime could not be restored"); + } else { + strcpy(reply, enable && _sensors->gpsUsesSerialUart(_prefs->bridge_uart) + ? "OK - UART GPS paused" : "OK"); + } + return; + } #endif // Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp. if (handleObserverSetCmd(sender_timestamp, config, reply)) return; @@ -4674,7 +4796,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, applied ? "OK" : "Error: ESP-NOW runtime change failed; setting saved"); #else - #ifdef WITH_RS232_BRIDGE +#if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) if (enable && _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart) && (!_sensors->gpsUsesSerialUart(_prefs->bridge_uart) @@ -4683,11 +4805,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } const uint8_t previous_enabled = _prefs->bridge_enabled; - const bool previous_running = _callbacks->isBridgeRunning(); + const bool previous_running = _callbacks->isRs232BridgeRunning(); _prefs->bridge_enabled = enable; - const bool applied = _callbacks->setBridgeState(enable); - if (applied) { - savePrefs(); + const bool applied = _callbacks->setRs232BridgeState(enable); + if (applied && trySavePrefs()) { #ifdef WITH_RS232_BRIDGE if (enable && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { strcpy(reply, "OK - UART GPS paused"); @@ -4698,12 +4819,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else { _prefs->bridge_enabled = previous_enabled; + _prefs->rs232_bridge_enabled = previous_enabled == 1 ? 1 : 0; bool restored = true; - if (_callbacks->isBridgeRunning() != previous_running + if (_callbacks->isRs232BridgeRunning() != previous_running || !previous_running) { // The false/false case still calls disable to delete a failed, // non-running heap-backed bridge instance. - restored = _callbacks->setBridgeState(previous_running); + restored = _callbacks->setRs232BridgeState(previous_running); } strcpy(reply, restored ? "Error: bridge state change failed; setting unchanged" @@ -4758,22 +4880,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (mesh::cli::parseUnsignedIntegerStrict(&config[12], baud) && baud >= 9600 && baud <= BRIDGE_MAX_BAUD) { const uint32_t previous_baud = _prefs->bridge_baud; - const bool previous_running = _callbacks->isBridgeRunning(); + const bool previous_running = _callbacks->isRs232BridgeRunning(); _prefs->bridge_baud = baud; const bool applied = -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#ifdef WITH_MQTT_BRIDGE + !_prefs->rs232_bridge_enabled || _callbacks->restartRs232Bridge(); #else - !_prefs->bridge_enabled || _callbacks->restartBridge(); + !_prefs->bridge_enabled || _callbacks->restartRs232Bridge(); #endif - if (applied) { - savePrefs(); + if (applied && trySavePrefs()) { strcpy(reply, "OK"); } else { _prefs->bridge_baud = previous_baud; const bool restored = previous_running - ? _callbacks->restartBridge() - : _callbacks->setBridgeState(false); + ? _callbacks->restartRs232Bridge() + : _callbacks->setRs232BridgeState(false); strcpy(reply, restored ? "Error: bridge failed to restart; baud unchanged" : "Error: bridge failed to restart; previous baud could not be restored"); @@ -4798,7 +4919,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep #endif } else { - if (_prefs->bridge_enabled + if ( +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled +#else + _prefs->bridge_enabled +#endif && _sensors->gpsSerialTransportMayConflict((uint8_t)uart) && (!_sensors->gpsUsesSerialUart((uint8_t)uart) || !_sensors->gpsSerialTransportCanYield((uint8_t)uart))) { @@ -4806,28 +4932,31 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } const uint8_t previous_uart = _prefs->bridge_uart; - const bool previous_running = _callbacks->isBridgeRunning(); + const bool previous_running = _callbacks->isRs232BridgeRunning(); _prefs->bridge_uart = (uint8_t)uart; const bool applied = -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#ifdef WITH_MQTT_BRIDGE + !_prefs->rs232_bridge_enabled || _callbacks->restartRs232Bridge(); #else - !_prefs->bridge_enabled || _callbacks->restartBridge(); + !_prefs->bridge_enabled || _callbacks->restartRs232Bridge(); #endif - if (!applied) { + if (!applied || !trySavePrefs()) { _prefs->bridge_uart = previous_uart; const bool restored = previous_running - ? _callbacks->restartBridge() - : _callbacks->setBridgeState(false); + ? _callbacks->restartRs232Bridge() + : _callbacks->setRs232BridgeState(false); strcpy(reply, restored ? "Error: bridge failed to restart; UART unchanged" : "Error: bridge failed to restart; previous UART could not be restored"); - } else if (_prefs->bridge_enabled + } else if ( +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled +#else + _prefs->bridge_enabled +#endif && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { - savePrefs(); strcpy(reply, "OK - UART GPS paused"); } else { - savePrefs(); strcpy(reply, "OK"); } } @@ -5323,7 +5452,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", _callbacks->getRole()); } else if (configKeyEquals(config, "bridge.type")) { sprintf(reply, "> %s", -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + "mqtt+rs232+espnow" +#elif defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) + "mqtt+rs232" +#elif defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) "mqtt+espnow" #elif defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) "rs232+espnow" @@ -5370,6 +5503,15 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "rx" : "tx"); #endif #ifdef WITH_RS232_BRIDGE + } else if (configKeyEquals(config, "rs232.enabled")) { + sprintf(reply, "> %s", +#ifdef WITH_MQTT_BRIDGE + _prefs->rs232_bridge_enabled ? "on" : "off"); +#else + _prefs->bridge_enabled ? "on" : "off"); +#endif + } else if (configKeyEquals(config, "rs232.running")) { + sprintf(reply, "> %s", _callbacks->isRs232BridgeRunning() ? "on" : "off"); } else if (configKeyEquals(config, "bridge.baud")) { sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud); } else if (configKeyEquals(config, "bridge.uart")) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2988718bb..985971de8 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -205,6 +205,10 @@ public: // 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; + // Independent UART intent in MQTT-combined profiles. Non-MQTT UART roles + // retain bridge_enabled as their legacy alias and mirror it on save. + // Appended after the profile markers, never beside the legacy bridge byte. + uint8_t rs232_bridge_enabled = 0; // Appended after the ESP-NOW intent. Zero keeps the board's legacy cadence. uint16_t gps_sync_interval_hours = 0; uint8_t retry_preset = 0; @@ -600,9 +604,12 @@ public: // 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); } + virtual bool setRs232BridgeState(bool enable) { return setBridgeState(enable); } virtual bool restartMqttBridge() { return restartBridge(); } virtual bool restartEspNowBridge() { return restartBridge(); } + virtual bool restartRs232Bridge() { return restartBridge(); } virtual bool isEspNowBridgeRunning() { return isBridgeRunning(); } + virtual bool isRs232BridgeRunning() const { return isBridgeRunning(); } virtual void restartBridgeSlot(int slot) { // Default: fall back to full restart diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index 43e9ee119..83592d93c 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -5,6 +5,12 @@ #ifdef WITH_RS232_BRIDGE +#ifdef ESP32 +// check_firmware_ram.py reserves this object plus the UART driver's runtime +// allocation even when a combined Full image starts with the UART disabled. +static_assert(sizeof(RS232Bridge) <= 4096, "Update the ESP32 UART heap budget"); +#endif + RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, int16_t tx_pin, mesh::PacketManager *mgr, mesh::RTCClock *rtc) @@ -12,9 +18,10 @@ RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, _tx_pin(tx_pin) {} void RS232Bridge::begin() { + _initialized = false; BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud); #if defined(ESP32) - ((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin); + if (!((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin)) return; #elif defined(NRF52_PLATFORM) // Tested with RAK_4631 and T114 // The Adafruit Uart object may already be active on its variant defaults. @@ -33,7 +40,11 @@ void RS232Bridge::begin() { ((HardwareSerial *)_serial)->begin(_prefs->bridge_baud); // Update bridge state +#if defined(ESP32) + _initialized = static_cast(*static_cast(_serial)); +#else _initialized = true; +#endif } void RS232Bridge::end() { diff --git a/test/fixtures/rs232_mqtt_runtime.cpp b/test/fixtures/rs232_mqtt_runtime.cpp new file mode 100644 index 000000000..0dddad4c9 --- /dev/null +++ b/test/fixtures/rs232_mqtt_runtime.cpp @@ -0,0 +1,387 @@ +// Real repeater methods and CLI dispatch are inserted by the Python harness. +#include +#include +#include +#include +#include +#include +#include +#include +#define BRIDGE_MAX_BAUD 115200 +#define PUB_KEY_SIZE 32 +#define MESH_DEBUG_PRINTLN(...) ((void)0) + +namespace mesh { +struct Packet { bool allowed = true; }; +struct LocalIdentity { uint8_t pub_key[32] = {}; }; +struct Utils { static void toHex(char* out, const uint8_t*, unsigned) { strcpy(out, "mock-node"); } }; +} +struct StrHelper { + static void strncpy(char* out, const char* in, size_t size) { + assert(size); std::strncpy(out, in, size - 1); out[size - 1] = 0; + } +}; +struct NodePrefs { + uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_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; + float freq = 915, bw = 125; + uint8_t sf = 7, cr = 5, disable_fwd = 0; + char bridge_secret[32] = {}, node_name[32] = "triple bridge"; +}; +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 { + bool (*filter)(void*, const mesh::Packet*) = nullptr; + void* filter_context = nullptr; +protected: + bool allow(mesh::Packet* packet) { return !filter || filter(filter_context, packet); } +public: + unsigned filters = 0; + 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; + void setPacketFilter(bool (*check)(void*, const mesh::Packet*), void* context) { + filter = check; filter_context = context; ++filters; + } +}; +class RS232Bridge : public AbstractBridge { + NodePrefs* prefs; + FakeUart& uart; + int rx, tx; + bool running = false; +public: + inline static unsigned live = 0, fail_starts = 0, fail_allocations = 0; + static void* operator new(size_t size, const std::nothrow_t&) noexcept { + if (fail_allocations) { --fail_allocations; return nullptr; } + return ::operator new(size, std::nothrow); + } + static void operator delete(void* pointer) noexcept { ::operator delete(pointer); } + unsigned loops = 0, sent = 0; + RS232Bridge(NodePrefs* p, FakeUart& u, int r, int t, void*, void*) + : prefs(p), uart(u), rx(r), tx(t) { ++live; } + ~RS232Bridge() override { --live; } + void begin() override { + assert(filters); ++uart.begins; + if (fail_starts) { --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* p) override { if (allow(p)) ++sent; } +}; +class ESPNowBridge : public AbstractBridge { + bool running = false; +public: + unsigned begins = 0, ends = 0, fail_starts = 0, loops = 0, sent = 0; + void begin() override { + assert(filters); ++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* p) override { if (allow(p)) ++sent; } +}; +struct MQTTNodeInfo { + const char* node_name; + float* freq; + float* bw; + uint8_t* sf; + uint8_t* cr; + uint8_t* repeat_flag; + bool repeat_when_nonzero; +}; +class MQTTBridge : public AbstractBridge { + bool running = false; +public: + inline static unsigned live = 0, fail_starts = 0; + unsigned begins = 0, ends = 0, loops = 0, sent = 0, received = 0; + MQTTBridge(MQTTNodeInfo, void*, void*, mesh::LocalIdentity*) { ++live; } + ~MQTTBridge() override { --live; } + void setDeviceID(const char*) {} + void setFirmwareVersion(const char*) {} + void setBoardModel(const char*) {} + void setBuildDate(const char*) {} + void setStatsSources(void*, void*, void*, void*) {} + void begin() override { + assert(filters); ++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* p) override { if (allow(p)) ++sent; } + void onPacketReceived(mesh::Packet* p) { if (allow(p)) ++received; } +}; +static uint32_t fake_millis = 100; +static uint32_t millis() { return fake_millis; } +static bool millisHasNowPassed(uint32_t deadline) { + return static_cast(fake_millis - deadline) >= 0; +} +struct FakeWiFi { bool connected = true; bool isConnected() const { return connected; } } WiFi; +struct FakeBoard { + bool ota = false, allow_start = true, allow_stop = true; + bool isOTAUpdateRunning() const { return ota; } + const char* getManufacturerName() const { return "synthetic ESP32"; } + bool startOTAUpdate(const char*, char* reply, bool) { + ota = allow_start; strcpy(reply, allow_start ? "Started: http://192.168.4.1/update" : "ERR: OTA failed"); + return allow_start; + } + bool stopOTAUpdate(char* reply) { + if (allow_stop) ota = false; + strcpy(reply, allow_stop ? "OK - OTA stopped" : "ERR: upload active"); return allow_stop; + } +}; +struct FakeCLI { + FakeBoard board; + FakeBoard* getBoard() { return &board; } + void* getObserverPrefs() { return nullptr; } +}; +struct FakeAlerter { void setBridge(MQTTBridge*) {} }; +struct Callbacks { + virtual ~Callbacks() = default; + virtual bool isBridgeRunning() const = 0; + virtual bool setBridgeState(bool) = 0; + virtual bool restartBridge() = 0; + virtual bool isRs232BridgeRunning() const = 0; + virtual bool setRs232BridgeState(bool) = 0; + virtual bool restartRs232Bridge() = 0; + virtual bool isEspNowBridgeRunning() = 0; + virtual bool setEspNowBridgeState(bool) = 0; + virtual bool restartEspNowBridge() = 0; + virtual bool isMqttBridgeRunning() = 0; + virtual bool setMqttBridgeState(bool) = 0; + virtual bool restartMqttBridge() = 0; +}; +class MyMesh : public Callbacks { +public: + NodePrefs _prefs; + RS232Bridge* bridge = nullptr; + MQTTBridge* mqtt_bridge = nullptr; + ESPNowBridge espnow_bridge; + uint8_t active_rs232_bridge_uart = 0; + uint32_t shared_espnow_retry_at = 0; + unsigned filter_checks = 0; + FakeSensors sensors; + FakeCLI _cli; + FakeAlerter _alerter; + mesh::LocalIdentity self_id; + void* _mgr = nullptr; void* _radio = nullptr; void* _ms = nullptr; + void* getRTCClock() { return nullptr; } + mesh::LocalIdentity getSelfId() { return self_id; } + const char* getFirmwareVer() { return "test"; } + const char* getBuildDate() { return "test"; } + bool allowTransportPacket(const mesh::Packet* p, unsigned mode) { + assert(mode == 2); ++filter_checks; return p->allowed; + } + @FILTER@ + 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(setRs232BridgeState(false)); + assert(setBridgeState(false)); + delete mqtt_bridge; + } + @LIFECYCLE@ +}; +@PARSERS@ +class CommonCLI { +public: + NodePrefs* _prefs; + MyMesh* _callbacks; + FakeSensors* _sensors; + FakeBoard* _board; + NodePrefs stored; + unsigned saves = 0; + bool fail_save = false; + explicit CommonCLI(MyMesh& mesh) : _prefs(&mesh._prefs), _callbacks(&mesh), + _sensors(&mesh.sensors), _board(mesh._cli.getBoard()) {} + void savePrefs() { ++saves; stored = *_prefs; } + bool trySavePrefs() { if (fail_save) return false; savePrefs(); return true; } + void set(const char* input, char* reply) { + char config[256] = {}; assert(strlen(input) < sizeof(config)); strcpy(config, input); + @SET_BRANCHES@ + } + bool get(const char* config, char* reply) { + @GET_BRANCHES@ + return false; + } + void run(const char* command, char* reply) { + if (false) { + @OTA@ + } + } +}; +static void expect_get(CommonCLI& cli, const char* key, const char* expected) { + char reply[256] = {}; cli.get(key, reply); assert(strcmp(reply, expected) == 0); +} +int main() { + { + MyMesh fresh; fresh.defaults(); CommonCLI cli(fresh); + assert(fresh._prefs.bridge_enabled && fresh._prefs.espnow_bridge_enabled); + assert(!fresh._prefs.rs232_bridge_enabled); + const unsigned before = Serial2.begins; + fresh.boot(); + assert(fresh.isMqttBridgeRunning() && fresh.isEspNowBridgeRunning()); + assert(!fresh.isRs232BridgeRunning() && !fresh.bridge && Serial2.begins == before); + expect_get(cli, "bridge.type", "> mqtt+rs232+espnow"); + expect_get(cli, "rs232.enabled", "> off"); + expect_get(cli, "rs232.running", "> off"); + } + MyMesh mesh; mesh.defaults(); mesh.boot(); CommonCLI cli(mesh); + char reply[256] = {}; + cli.set("rs232.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isRs232BridgeRunning()); + assert(Serial2.rx == 5 && Serial2.tx == 6 && Serial2.baud == 115200); + assert(mesh.sensors.yield_calls == 0 && mesh.bridgesPreventSleep()); + expect_get(cli, "rs232.enabled", "> on"); expect_get(cli, "rs232.running", "> on"); + mesh::Packet packet; + mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1); + assert(mesh.espnow_bridge.sent == 1 && mesh.espnow_bridge.loops == 1); + assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1); + assert(mesh.mqtt_bridge->loops == 0); // Its actual task services MQTT independently. + assert(mesh.filter_checks == 4); + packet.allowed = false; + mesh.routeRx(&packet); mesh.routeTx(&packet); + assert(mesh.filter_checks == 8 && mesh.bridge->sent == 1 && mesh.espnow_bridge.sent == 1); + assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1); + packet.allowed = true; + const unsigned wifi_starts = mesh.mqtt_bridge->begins, wireless_starts = mesh.espnow_bridge.begins; + cli.set("bridge.baud 57600", reply); + assert(strcmp(reply, "OK") == 0 && Serial2.baud == 57600); + cli.set("bridge.uart 2", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isRs232BridgeRunning()); + assert(mesh.mqtt_bridge->begins == wifi_starts && mesh.espnow_bridge.begins == wireless_starts); + const unsigned saves = cli.saves; + for (const char* invalid : {"rs232.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.isRs232BridgeRunning() && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + } + RS232Bridge::fail_starts = 1; + cli.set("bridge.baud 19200", reply); + assert(strstr(reply, "baud unchanged") && cli.saves == saves); + assert(mesh._prefs.bridge_baud == 57600 && Serial2.baud == 57600 && mesh.isRs232BridgeRunning()); + assert(mesh.mqtt_bridge->begins == wifi_starts && mesh.espnow_bridge.begins == wireless_starts); + cli.set("rs232.enabled off", reply); assert(strcmp(reply, "OK") == 0 && !mesh.bridge); + const unsigned disabled_saves = cli.saves; + RS232Bridge::fail_starts = 1; + cli.set("rs232.enabled on", reply); + assert(strstr(reply, "setting unchanged") && cli.saves == disabled_saves && !mesh.bridge); + assert(!mesh._prefs.rs232_bridge_enabled && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + RS232Bridge::fail_allocations = 1; + cli.set("rs232.enabled on", reply); + assert(strstr(reply, "setting unchanged") && !mesh.bridge && !mesh._prefs.rs232_bridge_enabled); + assert(cli.saves == disabled_saves && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + cli.fail_save = true; + cli.set("rs232.enabled on", reply); + assert(strstr(reply, "setting unchanged") && !mesh.bridge && !mesh._prefs.rs232_bridge_enabled); + assert(cli.saves == disabled_saves && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + cli.fail_save = false; + cli.set("rs232.enabled on", reply); assert(strcmp(reply, "OK") == 0); + const unsigned saved_after_enable = cli.saves; + cli.fail_save = true; + cli.set("rs232.enabled off", reply); + assert(strstr(reply, "setting unchanged") && mesh.isRs232BridgeRunning() && mesh._prefs.rs232_bridge_enabled); + cli.set("bridge.baud 19200", reply); + assert(strstr(reply, "baud unchanged") && mesh._prefs.bridge_baud == 57600 && Serial2.baud == 57600); + cli.set("bridge.uart 2", reply); + assert(strstr(reply, "UART unchanged") && mesh._prefs.bridge_uart == 2 && mesh.isRs232BridgeRunning()); + assert(cli.saves == saved_after_enable && mesh.mqtt_bridge->begins == wifi_starts); + assert(mesh.espnow_bridge.begins == wireless_starts); + cli.fail_save = false; + // Saved UART settings recreate a running independent bridge after reboot. + { + MyMesh restarted; restarted.defaults(); restarted._prefs = cli.stored; restarted.boot(); + assert(restarted.isRs232BridgeRunning() && restarted.isMqttBridgeRunning()); + assert(restarted._prefs.bridge_baud == 57600 && restarted.isEspNowBridgeRunning()); + } + // ESP-NOW format/channel changes and its legacy alias never restart the UART. + const unsigned uart_before_wireless = Serial2.begins; + cli.set("bridge.channel 6", reply); cli.set("bridge.secret three-transports", reply); + cli.set("bridge.format raw", reply); assert(strncmp(reply, "OK", 2) == 0); + assert(Serial2.begins == uart_before_wireless && mesh.mqtt_bridge->begins == wifi_starts); + cli.set("bridge.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && !mesh.isEspNowBridgeRunning()); + assert(mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning()); + cli.set("mqtt.enabled off", reply); + assert(strcmp(reply, "OK") == 0 && !mesh.isMqttBridgeRunning()); + assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep()); + const unsigned uart_tx_before = mesh.bridge->sent; + mesh.routeRx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == uart_tx_before + 1); + cli.set("espnow.enabled on", reply); assert(strcmp(reply, "OK") == 0); + cli.set("mqtt.enabled on", reply); assert(strcmp(reply, "OK") == 0); + mesh.serviceBridges(); assert(mesh.isEspNowBridgeRunning()); + // Browser OTA releases only WiFi users and leaves the enabled UART running. + const unsigned uart_before_ota = Serial2.begins; + cli.run("start ota ap", reply); + assert(mesh._cli.board.ota && !mesh.isMqttBridgeRunning() && !mesh.isEspNowBridgeRunning()); + assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota); + for (unsigned tick = 0; tick < 5; ++tick) { fake_millis += 5001; mesh.serviceBridges(); } + assert(!mesh.isEspNowBridgeRunning() && mesh.isRs232BridgeRunning()); + mesh._cli.board.allow_stop = false; cli.run("stop ota", reply); + assert(strcmp(reply, "ERR: upload active") == 0 && !mesh.isMqttBridgeRunning()); + mesh._cli.board.allow_stop = true; cli.run("stop ota", reply); mesh.serviceBridges(); + assert(!mesh._cli.board.ota && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota); + // Wireless startup failure retries without stopping or reallocating UART. + cli.set("espnow.enabled off", reply); mesh.espnow_bridge.fail_starts = 2; + cli.set("espnow.enabled on", reply); mesh.serviceBridges(); + const unsigned retry_starts = mesh.espnow_bridge.begins; + mesh.serviceBridges(); assert(mesh.espnow_bridge.begins == retry_starts); + fake_millis += 5001; mesh.serviceBridges(); assert(!mesh.isEspNowBridgeRunning()); + fake_millis += 5001; mesh.serviceBridges(); assert(mesh.isEspNowBridgeRunning()); + assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota); + // Every switch, parameter change and rollback leaves other transports intact. + for (unsigned cycle = 0; cycle < 100; ++cycle) { + cli.set("rs232.enabled off", reply); + assert(!mesh.bridge && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning()); + cli.set("rs232.enabled on", reply); + assert(mesh.isRs232BridgeRunning() && RS232Bridge::live == 1); + } + cli.set("rs232.enabled off", reply); cli.set("espnow.enabled off", reply); cli.set("mqtt.enabled off", reply); + assert(!mesh.bridgesPreventSleep() && RS232Bridge::live == 0); + cli.run("start ota", reply); cli.run("stop ota", reply); mesh.serviceBridges(); + assert(!mesh.isRs232BridgeRunning() && !mesh.isMqttBridgeRunning() && !mesh.isEspNowBridgeRunning()); + assert(mesh.sensors.yield_calls == 0); + puts("Three transport runtime checks passed"); +} diff --git a/test/fixtures/rs232_runtime/json.cpp b/test/fixtures/rs232_runtime/json.cpp index 551d82ade..ae8c49fd7 100644 --- a/test/fixtures/rs232_runtime/json.cpp +++ b/test/fixtures/rs232_runtime/json.cpp @@ -6,7 +6,7 @@ #include struct NodePrefs : ConfigSerializer { - uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0; + uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0, rs232_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; @@ -47,8 +47,14 @@ struct CLI { } bool import(FakeFS* fs) { bool loaded = false, is_upgrade = false; +#ifdef WITH_MQTT_BRIDGE + bool node_prefs_needs_migration = false; +#endif @IMPORT@ assert(loaded == is_upgrade); +#ifdef WITH_MQTT_BRIDGE + assert(node_prefs_needs_migration == loaded); +#endif return loaded; } }; @@ -60,17 +66,27 @@ int main() { "{name:\"imported node\",bridge:{en:1,uart:1}}", "{name:\"imported node\",bridge:{en:1,uart:2}}", "{name:\"imported node\",bridge:{en:0,uart:2}}", + "{name:\"imported node\",bridge:{en:1,uart:2,rs232:1}}", }) { CLI cli; cli.prefs.espnow_bridge_enabled = 1; // An old unused flag cannot prove intent. + cli.prefs.rs232_bridge_enabled = 1; // Reused objects cannot donate UART intent. FakeFS fs{json}; - assert(cli.import(&fs) && cli.saves == 1); + assert(cli.import(&fs)); +#ifdef WITH_MQTT_BRIDGE + assert(cli.saves == 0); +#else + assert(cli.saves == 1); +#endif assert(strcmp(cli.prefs.node_name, "imported node") == 0); #if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) \ && !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) assert(cli.prefs.bridge_enabled == 0); +#elif defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) + assert(cli.prefs.bridge_enabled == (strstr(json, "en:1") != nullptr)); + assert(cli.prefs.rs232_bridge_enabled == 0 && cli.prefs.bridge_uart == 2); #elif defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) - const bool explicit_uart = strstr(json, "uart:1") || strstr(json, "uart:2"); + const bool explicit_uart = 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 @@ -85,8 +101,10 @@ int main() { #else assert(cli.prefs.espnow_bridge_enabled == 1); #endif +#ifndef WITH_MQTT_BRIDGE assert(cli.saved_uart == cli.prefs.bridge_uart && cli.saved_enabled == cli.prefs.bridge_enabled); assert(cli.saved_secondary == cli.prefs.espnow_bridge_enabled); +#endif } // Malformed JSON may partially apply scalar fields before the parser rejects it. CLI broken; diff --git a/test/fixtures/rs232_runtime/main.cpp b/test/fixtures/rs232_runtime/main.cpp index 0f099440d..09a54e57d 100644 --- a/test/fixtures/rs232_runtime/main.cpp +++ b/test/fixtures/rs232_runtime/main.cpp @@ -29,7 +29,7 @@ template T constrain(T value, int low, int high) { } struct NodePrefs { - uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0; + uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_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; @@ -117,6 +117,9 @@ struct Callbacks { virtual bool isBridgeRunning() const = 0; virtual bool setBridgeState(bool) = 0; virtual bool restartBridge() = 0; + virtual bool isRs232BridgeRunning() const = 0; + virtual bool setRs232BridgeState(bool) = 0; + virtual bool restartRs232Bridge() = 0; #ifdef WITH_ESPNOW_BRIDGE virtual bool isEspNowBridgeRunning() = 0; virtual bool setEspNowBridgeState(bool) = 0; @@ -186,6 +189,7 @@ public: explicit CommonCLI(MyMesh& mesh) : _prefs(&mesh._prefs), _callbacks(&mesh), _sensors(&mesh.sensors) {} void savePrefs() { ++saves; stored = *_prefs; } + bool trySavePrefs() { savePrefs(); return true; } void load(FakeFile file) { @INITIAL_UART@ @LOAD_UART@ @@ -212,7 +216,7 @@ static void check_upgrade(bool has_tail, uint8_t saved_uart, uint8_t 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; + const bool normal_upgrade = !has_tail || saved_uart != 2; #else const bool normal_upgrade = false; #endif @@ -241,7 +245,7 @@ int main() { 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. + // Only a supported legacy UART proves intent; unsupported ports fail closed. for (uint8_t enabled : {uint8_t{0}, uint8_t{1}}) { check_upgrade(false, 0, enabled); check_upgrade(true, 0, enabled); diff --git a/test/fixtures/rs232_runtime/prefs.cpp b/test/fixtures/rs232_runtime/prefs.cpp index 235449b54..11c662f8c 100644 --- a/test/fixtures/rs232_runtime/prefs.cpp +++ b/test/fixtures/rs232_runtime/prefs.cpp @@ -1,3 +1,4 @@ +#include struct Capture { std::vector bytes; std::map offsets; @@ -9,9 +10,39 @@ struct Capture { size_t offset(const void* field) const { return offsets.at(field); } }; -int main() { +// The same executable can exchange actual serialized files with another profile. +// This catches role transitions which cannot be represented by a single macro set. +int main(int argc, char** argv) { + if (argc > 1) { + assert(argc == 7); + CommonCLI cli; + const bool writing = strcmp(argv[1], "write") == 0; + if (writing) { + cli.prefs.bridge_enabled = atoi(argv[3]); + cli.prefs.espnow_bridge_enabled = atoi(argv[4]); + cli.prefs.rs232_bridge_enabled = atoi(argv[5]); + cli.prefs.bridge_uart = 2; + cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); + assert(cli._common_save_succeeded); + const auto& image = cli.fs.files.at("/com_prefs"); + std::ofstream stream(argv[2], std::ios::binary); + stream.write(reinterpret_cast(image.data()), image.size()); + assert(stream.good()); + } else { + std::ifstream stream(argv[2], std::ios::binary); + assert(stream.good()); + cli.fs.files["/com_prefs"] = std::vector( + std::istreambuf_iterator(stream), std::istreambuf_iterator()); + cli.loadPrefsInt(&cli.fs, "/com_prefs"); + assert(cli.prefs.bridge_enabled == atoi(argv[3])); + assert(cli.prefs.espnow_bridge_enabled == atoi(argv[4])); + assert(cli.prefs.rs232_bridge_enabled == atoi(argv[5])); + assert(cli.prefs.bridge_uart == atoi(argv[6])); + } + return 0; + } CommonCLI writer; - strcpy(writer.prefs.node_name, "MKE combined repeater"); + strcpy(writer.prefs.node_name, "Combined repeater"); strcpy(writer.prefs.password, "test-password"); writer.prefs.bridge_uart = 2; writer.prefs.bridge_baud = 57600; @@ -22,80 +53,134 @@ int main() { 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); + const size_t esp_marker_offset = channel_offset + 1; + const size_t rs_intent_offset = initial.offset(&writer.prefs.rs232_bridge_enabled); + const size_t rs_marker_offset = channel_offset + 3; + assert(initial.bytes.size() == 877 && channel_offset == 873); + assert(rs_intent_offset == channel_offset + 2); #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); + assert(initial.bytes[esp_marker_offset] == 0xA1); #else - assert(initial.bytes.size() == 874 && channel_offset == initial.bytes.size() - 1); + assert(initial.bytes[esp_marker_offset] == 0); +#endif +#ifdef WITH_RS232_BRIDGE + assert(initial.bytes[rs_marker_offset] == 0xB1); +#else + assert(initial.bytes[rs_marker_offset] == 0); #endif assert(initial.offset(writer.prefs.node_name) == 4); assert(initial.offset(&writer.prefs.freq) == 72); - assert(initial.offset(&writer.prefs.bridge_enabled) == 127); + assert(enabled_offset == 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"); + for (uint8_t uart_intent : {uint8_t{0}, uint8_t{1}}) { + writer.prefs.bridge_enabled = primary; + writer.prefs.espnow_bridge_enabled = secondary; + writer.prefs.rs232_bridge_enabled = uart_intent; + 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"); #if defined(ESPNOW_BRIDGE_MERGED) && !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - // The new sole bridge stores independent ESP-NOW intent and mirrors the - // legacy primary alias, even if an older caller supplied different flags. - assert(reader.prefs.bridge_enabled == secondary); + assert(reader.prefs.bridge_enabled == secondary); #else - assert(reader.prefs.bridge_enabled == primary); + assert(reader.prefs.bridge_enabled == primary); #endif - assert(reader.prefs.espnow_bridge_enabled == secondary); + assert(reader.prefs.espnow_bridge_enabled == secondary); #ifdef WITH_RS232_BRIDGE - assert(reader.prefs.bridge_uart == 2); + assert(reader.prefs.bridge_uart == 2); +#ifdef WITH_MQTT_BRIDGE + assert(reader.prefs.rs232_bridge_enabled == uart_intent); #else - assert(reader.prefs.bridge_uart == 0); + assert(reader.prefs.rs232_bridge_enabled == primary); #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); +#else + assert(reader.prefs.bridge_uart == 0 && reader.prefs.rs232_bridge_enabled == 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)); + writer.prefs.rs232_bridge_enabled = 1; + writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common); + assert(writer._common_save_succeeded); + const auto on = writer.fs.files.at("/com_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); +#ifdef WITH_RS232_BRIDGE + // Unmarked previous MQTT files cannot prove UART intent, even when they + // contain UART2 and primary enabled. Dedicated UART profiles retain context. + for (size_t length : {uart_offset, esp_marker_offset, rs_intent_offset}) { + auto old = on; old.resize(length); 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); +#ifdef WITH_MQTT_BRIDGE + assert(reader.prefs.bridge_enabled == 1 && reader.prefs.rs232_bridge_enabled == 0); +#elif defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON) + const bool legacy_uart_present = length > uart_offset; + assert(reader.prefs.bridge_enabled == legacy_uart_present); + assert(reader.prefs.rs232_bridge_enabled == legacy_uart_present); +#endif + assert(reader.prefs.bridge_uart == 2 && reader._com_prefs_needs_upgrade); } - // Older dedicated MKE builds selected Serial2 but persisted the UART1 fallback. - auto dedicated = on.bytes; - dedicated[uart_offset] = 1; + // A partial new tail, invalid marker or invalid boolean must never fall back + // to a different transport's primary setting. Short reads fail closed too. + for (unsigned invalid = 0; invalid <= 255; ++invalid) { + if (invalid == 0xB1) continue; + CommonCLI reader; + auto corrupt = on; corrupt[rs_marker_offset] = static_cast(invalid); + reader.fs.files["/com_prefs"] = corrupt; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.rs232_bridge_enabled == 0 && reader._com_prefs_needs_upgrade); +#ifdef WITH_MQTT_BRIDGE + assert(reader.prefs.bridge_enabled == 1); +#else + assert(reader.prefs.bridge_enabled == 0); +#endif + } + for (unsigned invalid = 2; invalid <= 255; ++invalid) { + CommonCLI reader; + auto corrupt = on; corrupt[rs_intent_offset] = static_cast(invalid); + reader.fs.files["/com_prefs"] = corrupt; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.rs232_bridge_enabled == 0); + } + for (size_t fault : {rs_intent_offset, rs_marker_offset}) { + CommonCLI reader; + reader.fs.files["/com_prefs"] = on; + reader.fs.fail_read_after = static_cast(fault); + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.rs232_bridge_enabled == 0); + } + CommonCLI torn; + torn.fs.files["/com_prefs"] = {on.begin(), on.begin() + rs_marker_offset}; + torn.loadPrefsInt(&torn.fs, "/com_prefs"); + assert(torn.prefs.rs232_bridge_enabled == 0); +#if defined(RS232_BRIDGE_MERGED) && !defined(WITH_MQTT_BRIDGE) + // Old Serial2 builds saved the generic UART1 fallback; known UART2 remains + // the safe resolved port, without losing a valid new independent intent. + auto dedicated = on; 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 +#endif #if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) for (unsigned invalid = 0; invalid <= 255; ++invalid) { if (invalid == 0xA1) continue; - auto old = on.bytes; - old[marker_offset] = static_cast(invalid); + auto old = on; old[esp_marker_offset] = static_cast(invalid); CommonCLI reader; reader.fs.files["/com_prefs"] = old; reader.loadPrefsInt(&reader.fs, "/com_prefs"); @@ -107,30 +192,23 @@ int main() { #endif assert(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); + auto old = on; old.resize(esp_marker_offset); CommonCLI reader; reader.fs.files["/com_prefs"] = old; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.espnow_bridge_enabled == 0); -#if !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - assert(reader.prefs.bridge_enabled == 0); -#else - assert(reader.prefs.bridge_enabled == 1); -#endif - reader.fs.files["/com_prefs"] = on.bytes; - reader.fs.fail_read_after = static_cast(marker_offset); + reader.fs.files["/com_prefs"] = on; + reader.fs.fail_read_after = static_cast(esp_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 + // Every append boundary must commit atomically for observer and plain writers. + for (size_t fault : {esp_marker_offset, rs_intent_offset, rs_marker_offset}) { + const auto previous = writer.fs.files.at("/com_prefs"); + writer.fs.fail_write_after = static_cast(fault); + writer.prefs.rs232_bridge_enabled = 0; + assert(!writer.trySavePrefs()); + assert(writer.fs.files.at("/com_prefs") == previous); + writer.fs.fail_write_after = -1; + } } diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index ca34d5695..5dee5289d 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -341,6 +341,23 @@ 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=") +# Full source substitution must preserve the normal repeater UART rather +# than retiring its legacy bridge while compiling an MQTT-only recipe. +for env_name, rx, tx in ( + ("Heltec_v3_repeater_observer_mqtt", 5, 6), + ("Heltec_WSL3_repeater_observer_mqtt", 5, 6), + ("RAK_3112_repeater_observer_mqtt", 5, 6), + ("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", 34, 25), +): + require(env_name, "build_flags", "WITH_MQTT_BRIDGE=1") + require(env_name, "build_flags", "WITH_RS232_BRIDGE=Serial2") + require(env_name, "build_flags", "WITH_RS232_BRIDGE_UART=2") + require(env_name, "build_flags", f"WITH_RS232_BRIDGE_RX={rx}") + require(env_name, "build_flags", f"WITH_RS232_BRIDGE_TX={tx}") + require(env_name, "build_flags", "RS232_BRIDGE_MERGED=1") + require(env_name, "build_src_filter", "helpers/bridges/RS232Bridge.cpp") + reject(env_name, "build_flags", "RS232_BRIDGE_DEFAULT_ON") + # 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. @@ -1606,6 +1623,19 @@ if is_redundant_bulk_build_target wio-e5-repeater_bridge_rs232; then fail "capacity-constrained Wio-E5 RS232 bridge was incorrectly merged" fi +( + BUILD_PROFILE_FOR_TARGET=full + for uart_target in Heltec_v3_repeater Heltec_WSL3_repeater RAK_3112_repeater \ + LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_; do + BUILD_EXPECTATIONS=() + declare_build_capability_contract "$uart_target" ESP32_PLATFORM + [[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.rs232=_ZN11RS232Bridge5beginEv"* ]] \ + || fail "$uart_target Full contract omitted the linked UART driver" + [[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.espnow=_ZN12ESPNowBridge5beginEv"* ]] \ + || fail "$uart_target Full contract omitted the linked ESP-NOW driver" + done +) + # A WiFi base's final -UENABLE_OTA must not win over the Full Companion's # source-only OTA overlay. That mismatch compiles out both the TCP terminal and # seeder while still leaving a superficially valid binary. diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 975cde25e..17759ccb9 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -49,6 +49,7 @@ COMMON_QUERY_EXCEPTIONS = { 'bootloader.ver': 'Installed bootloader identity', 'bridge.running': 'Live bridge state; set bridge.enabled controls intent', 'espnow.running': 'Live ESP-NOW bridge state; set espnow.enabled controls intent', + 'rs232.running': 'Live UART bridge state; set rs232.enabled controls intent', 'bridge.type': 'Build-selected bridge transport', 'password': 'Written by the password command, not set password', 'public.key': 'Derived from identity; written through set prv.key', @@ -87,6 +88,10 @@ class CLISettingsContractTest(unittest.TestCase): 'rs232_gps': ['WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'ENV_INCLUDE_GPS'], 'rs232_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'WITH_ESPNOW_BRIDGE'], + 'mqtt_rs232_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', + 'WITH_MQTT_BRIDGE', 'WITH_RS232_BRIDGE', 'WITH_ESPNOW_BRIDGE'], + 'mqtt_rs232': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', + 'WITH_MQTT_BRIDGE', 'WITH_RS232_BRIDGE'], 'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'], 'lr2021': ['USE_LR2021'], } diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index 3e0c8bf66..3f62b7c87 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -86,7 +86,7 @@ int main() { auto original=cli.fs.files["/com_prefs"]; // Appending preferences must not move the established preamble bytes. const size_t preamble_offset=866; - assert(original.size()==874); + assert(original.size()==877); assert(original[preamble_offset]==48 && original[preamble_offset+1]==0); assert(original[preamble_offset+2]==1); assert(original[preamble_offset+3]==0x50 && original[preamble_offset+4]==0x01); diff --git a/test/test_common_usb_debug.py b/test/test_common_usb_debug.py index e1cf8ccf8..dd7677406 100644 --- a/test/test_common_usb_debug.py +++ b/test/test_common_usb_debug.py @@ -159,7 +159,7 @@ int main() { cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); assert(cli._common_save_succeeded); const auto baseline = cli.fs.files["/com_prefs"]; - assert(baseline.size() == 874); + assert(baseline.size() == 877); const size_t debug_offset = 871, trace_offset = 872; const size_t logging_offset = 863, preamble_offset = 866; const size_t espnow_offset = 868, gps_offset = 869; diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index ea199bf8d..c197cc35d 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1284,6 +1284,40 @@ assert(!picker.runtimeDirections(staleBridgeMetadata.profiles.find(profile => profile.target === bridgeTargets[0]), {mode: 'espnow'}).some(section => section.title === 'ESP-NOW bridge')); console.log('combined UART/ESP-NOW picker modes, independent commands and historical compatibility passed'); +// MQTT, ESP-NOW and UART coexist in the same qualified Full artifact. UART +// instructions must never use MQTT's historical ESP-NOW bridge alias. +const tripleTargets = ['Heltec_v3_repeater', 'Heltec_WSL3_repeater', + 'RAK_3112_repeater', 'LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_'] + .map(target => target + '-full-usb-wifi'); +const tripleCatalog = picker.buildCatalog([ + release(bridgeFamily, '2026-10-05T00:00:00Z', tripleTargets.map(target => + asset(target + '-ota-' + bridgeFamily + '.bin'))), +], { + familyTag: bridgeFamily, + profiles: Object.fromEntries(tripleTargets.map(target => [target, { + platform: 'ESP32_PLATFORM', rs232: true, mqtt: true, espnowBridge: true, + updateMethods: ['wifi', 'lora'], + }])), +}); +for (const profile of tripleCatalog.profiles) { + assert.deepStrictEqual(picker.profileFieldValues(profile, 'mode'), + ['standard', 'rs232', 'espnow']); + const directions = picker.runtimeDirections(profile, {mode: 'rs232'}); + const uart = directions.find(section => /^RS232 bridge/.test(section.title)); + const wireless = directions.find(section => section.title === 'ESP-NOW bridge'); + const mqtt = directions.find(section => section.title === 'MQTT broker connections'); + assert(uart && wireless && mqtt); + assert.deepStrictEqual(uart.actions.map(action => action.commands), [ + ['set rs232.enabled on', 'get rs232.running'], ['set rs232.enabled off'], + ['set rs232.enabled off', 'set bridge.baud 115200', 'set rs232.enabled on'], + ]); + assert(commands([wireless]).includes('set espnow.enabled on')); + assert(commands([mqtt]).includes('set mqtt.enabled on')); + assert(!commands([uart, wireless, mqtt]).some(command => command.includes('bridge.enabled'))); +} +assert.strictEqual(tripleCatalog.profiles.length, tripleTargets.length); +console.log('three-transport Full picker uses independent UART, MQTT and ESP-NOW controls'); + // Partition expansion uses the packager's lookup and published utility assets, // not hardware labels or guessed slugs (several historical names differ). const migrationFamily = currentControls.familyTag; diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 8dfcb8f34..4b9f72b97 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -173,6 +173,21 @@ class FirmwareRamTest(unittest.TestCase): }, "v4_companion") self.assertEqual(default, expanded) + def test_combined_uart_reserves_heap_beside_mqtt_before_enable(self): + defines = {"WITH_MQTT_BRIDGE": 1, "WIFI_OTA_SEEDER": 1} + mqtt = ram.requirements("ESP32_PLATFORM", defines, "v3_repeater") + combined = ram.requirements("ESP32_PLATFORM", { + **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, + }, "v3_repeater") + self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 8192) + self.assertEqual(combined["components"]["uart_bridge_and_driver"], 8192) + # A selectable alternate port does not create two UART owners. + alternate = ram.requirements("ESP32_PLATFORM", { + **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, + "WITH_RS232_BRIDGE_ALT": "Serial1", + }, "v3_repeater") + self.assertEqual(alternate, combined) + def test_published_image_tables_match_elf_and_use_its_own_reservations(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "reference.elf" diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py index fbcd1e4ce..508910b3d 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -20,6 +20,38 @@ def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM", class ReleaseFullSelectionTest(unittest.TestCase): + def test_full_uart_source_substitution_requires_linked_driver_proof(self): + for base in ("Heltec_v3_repeater", "Heltec_WSL3_repeater", "RAK_3112_repeater", + "LilyGo_TLora_V2_1_1_6_repeater"): + for suffix in ("", "_observer_mqtt", "_observer_mqtt_"): + for fault in ("missing_capability", "missing_proof", "false_proof", "unverified"): + image = record(base + suffix, capabilities=("bridge.rs232", "bridge.espnow")) + manifest = image["manifest"] + if fault == "missing_capability": + manifest["capabilities"].remove("bridge.rs232") + elif fault == "missing_proof": + manifest["verification"] = [] + elif fault == "false_proof": + manifest["verification"][0]["present"] = False + else: + manifest["verified"] = False + with self.subTest(base=base, suffix=suffix, fault=fault): + with self.assertRaisesRegex(ValueError, "verified RS232 bridge"): + select_ordinary_full_records([image]) + + def test_new_combined_full_displaces_stale_plain_and_both_dedicated_bridges(self): + for base in ("Heltec_v3_repeater", "Heltec_WSL3_repeater", "RAK_3112_repeater", + "LilyGo_TLora_V2_1_1_6_repeater"): + stale = record(base, capabilities=("bridge.espnow",)) + primary = record(base + "_observer_mqtt_", + capabilities=("bridge.rs232", "bridge.espnow")) + for profile in ("standard", "full"): + old = [record(base + "_bridge_" + bridge, profile) + for bridge in ("rs232", "espnow")] + for inputs in ([stale, primary] + old, old + [primary, stale]): + with self.subTest(base=base, profile=profile): + self.assertEqual(select_ordinary_full_records(inputs), [primary]) + def test_meshtower_sd_primary_filters_stale_internal_images_without_relabeling(self): primary = "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" variants = [] @@ -63,7 +95,7 @@ class ReleaseFullSelectionTest(unittest.TestCase): inputs = [ record("Heltec_v3_repeater_observer_mqtt"), record("Heltec_v3_repeater_bridge_espnow"), - record("Heltec_v3_repeater", capabilities=("bridge.espnow",)), + record("Heltec_v3_repeater", capabilities=("bridge.espnow", "bridge.rs232")), record("Heltec_v3_repeater", "standard"), record("Heltec_v3_room_server"), record("Station_G2_repeater"), @@ -71,7 +103,7 @@ class ReleaseFullSelectionTest(unittest.TestCase): record("Tbeam_SX1262_repeater_bridge_espnow"), 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("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", capabilities=("bridge.rs232",)), record("Meshadventurer_sx1262_repeater"), record("Meshadventurer_sx1262_repeater_bridge_espnow"), record("RAK_4631_repeater", platform="NRF52_PLATFORM"), @@ -158,7 +190,7 @@ class ReleaseFullSelectionTest(unittest.TestCase): 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",)) + primary = record("Heltec_v3_repeater", capabilities=("bridge.espnow", "bridge.rs232")) other_board = record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",)) for profile in ("full", "standard"): legacy = record("Heltec_v3_repeater_bridge_espnow", profile, capabilities=("bridge.espnow",)) @@ -221,7 +253,7 @@ class ReleaseFullSelectionTest(unittest.TestCase): 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",)) + capabilities=("bridge.espnow", "bridge.rs232")) legacy = record("LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow", capabilities=("bridge.espnow",)) for inputs in ([observer, legacy], [legacy, observer]): diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py index 9a334fe68..95e76b0ca 100644 --- a/test/test_nrf52_variant_contracts.py +++ b/test/test_nrf52_variant_contracts.py @@ -398,7 +398,7 @@ class Nrf52VariantContractsTest(unittest.TestCase): cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() self.assertIn("bool isBridgeRunning() const override", header) - self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x01", implementation) + self.assertIn("if (isRs232BridgeRunning()) reply_data[8] |= 0x01", implementation) self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x03", implementation) boot_failure = implementation[ implementation.index("if (!bridge || !beginRS232Bridge())"): @@ -406,7 +406,7 @@ class Nrf52VariantContractsTest(unittest.TestCase): "if (!bridge || !beginRS232Bridge())" )) ] - self.assertIn("setBridgeState(false)", boot_failure) + self.assertIn("setRs232BridgeState(false)", boot_failure) self.assertNotIn("_prefs.bridge_enabled", boot_failure) self.assertIn("parseOnOffStrict(&config[15], enable)", cli) self.assertIn("parseUnsignedIntegerStrict(&config[12], baud)", cli) @@ -417,7 +417,7 @@ class Nrf52VariantContractsTest(unittest.TestCase): self.assertIn('_prefs->bridge_pkt_src ? "rx" : "tx"', cli) self.assertIn("!defined(RS232_BRIDGE_DEFAULT_ON)", cli) - def test_default_on_rs232_profile_skips_default_off_tail_migration(self) -> None: + def test_default_on_rs232_profile_retains_legacy_alias_and_checks_new_intent(self) -> None: cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text() dedicated = ini_section( @@ -426,17 +426,18 @@ class Nrf52VariantContractsTest(unittest.TestCase): self.assertIn("extends = env:solarxiao_30S_repeater", dedicated) self.assertRegex(dedicated, r"-D\s+RS232_BRIDGE_DEFAULT_ON=1\b") - guard = ( - "#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n" - " && !defined(RS232_BRIDGE_DEFAULT_ON)" - ) - # Declaration, persisted-tail detection, and migration must share the - # same guard. A broader use breaks dedicated default-on bridge builds. + # Dedicated compatibility profiles retain their old primary UART alias; + # every current UART profile checks the independent marker before a + # newer MQTT image can donate intent. Executable nRF52/default-on and + # cross-profile round trips are covered by test_rs232_runtime.py. 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) + self.assertTrue("has_runtime_rs232_intent = rs232_profile == 0xB1" in binary_loader) + self.assertTrue("&& rs232_intent <= 1" in binary_loader) + self.assertTrue("if (!has_runtime_rs232_intent)" in binary_loader) + self.assertTrue("legacy_uart_intent" in binary_loader) + self.assertTrue("_prefs->bridge_enabled = _prefs->rs232_bridge_enabled;" in binary_loader) def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None: recipe = ( diff --git a/test/test_ota_channel_persistence.py b/test/test_ota_channel_persistence.py index cdd08823c..dabf861b1 100644 --- a/test/test_ota_channel_persistence.py +++ b/test/test_ota_channel_persistence.py @@ -66,7 +66,10 @@ void testRoundTripsAndLayout() { const size_t channel_offset = original.offset(&cli.prefs.ota_channel); const size_t trace_offset = original.offset(&cli.prefs.trace_when_repeat_off); assert(channel_offset == trace_offset + sizeof(cli.prefs.trace_when_repeat_off)); - assert(channel_offset + sizeof(cli.prefs.ota_channel) == original.bytes.size()); + assert(channel_offset + sizeof(cli.prefs.ota_channel) + 3 == original.bytes.size()); + assert(original.bytes[channel_offset + 1] == 0); // reserved ESP-NOW profile + assert(original.offset(&cli.prefs.rs232_bridge_enabled) == channel_offset + 2); + assert(original.bytes[channel_offset + 2] == 0 && original.bytes.back() == 0); // The established common core remains at its public file offsets. assert(original.offset(cli.prefs.node_name) == 4); assert(original.offset(&cli.prefs.freq) == 72); diff --git a/test/test_repeater_trace_repeat.py b/test/test_repeater_trace_repeat.py index 0efa9246d..a185dbfee 100644 --- a/test/test_repeater_trace_repeat.py +++ b/test/test_repeater_trace_repeat.py @@ -21,7 +21,7 @@ int main() { cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); assert(cli._common_save_succeeded); const auto baseline = cli.fs.files["/com_prefs"]; - assert(baseline.size() == 874); + assert(baseline.size() == 877); const size_t trace_offset = 872, debug_offset = 871, gps_offset = 869; assert(baseline[trace_offset] == 0 && baseline[debug_offset] == 1); assert(baseline[866] == 48 && baseline[867] == 0 && baseline[868] == 1); diff --git a/test/test_rs232_driver_lifecycle.py b/test/test_rs232_driver_lifecycle.py new file mode 100644 index 000000000..8ae997552 --- /dev/null +++ b/test/test_rs232_driver_lifecycle.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Execute the production ESP32 UART begin/end and bridge state checks.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +FIXTURE = r''' +#include +#include +#define ESP32 1 +#define BRIDGE_DEBUG_PRINTLN(...) ((void)0) +struct NodePrefs { uint32_t bridge_baud = 57600; }; +struct Stream { virtual ~Stream() = default; }; +struct HardwareSerial : Stream { + bool pins_ok = true, begin_ok = true, installed = false; + unsigned pin_calls = 0, begin_calls = 0, end_calls = 0; + int16_t rx = -1, tx = -1; + uint32_t baud = 0; + bool setPins(int16_t selected_rx, int16_t selected_tx) { + ++pin_calls; rx = selected_rx; tx = selected_tx; return pins_ok; + } + void begin(uint32_t selected_baud) { ++begin_calls; baud = selected_baud; installed = begin_ok; } + explicit operator bool() const { return installed; } + void end() { ++end_calls; installed = false; } +}; +struct BridgeBase { + bool _initialized = false; + @RUNNING@ +}; +struct RS232Bridge : BridgeBase { + NodePrefs* _prefs; + Stream* _serial; + int16_t _rx_pin, _tx_pin; + RS232Bridge(NodePrefs& prefs, HardwareSerial& serial) + : _prefs(&prefs), _serial(&serial), _rx_pin(5), _tx_pin(6) {} + void begin(); + void end(); +}; +@BEGIN@ +@END@ +int main() { + NodePrefs prefs; + HardwareSerial serial; + RS232Bridge bridge(prefs, serial); + assert(!bridge.isRunning()); + serial.pins_ok = false; + bridge.begin(); + assert(!bridge.isRunning() && serial.pin_calls == 1 && serial.begin_calls == 0); + serial.pins_ok = true; serial.begin_ok = false; + bridge.begin(); + assert(!bridge.isRunning() && !serial.installed && serial.begin_calls == 1); + serial.begin_ok = true; + bridge.begin(); + assert(bridge.isRunning() && serial.installed && serial.begin_calls == 2); + assert(serial.rx == 5 && serial.tx == 6 && serial.baud == 57600); + bridge.end(); + assert(!bridge.isRunning() && !serial.installed && serial.end_calls == 1); + // A failed restart must clear its former state, and a later retry can recover. + bridge.begin(); assert(bridge.isRunning()); + serial.pins_ok = false; + bridge.begin(); assert(!bridge.isRunning()); + bridge.end(); serial.pins_ok = true; prefs.bridge_baud = 19200; + bridge.begin(); assert(bridge.isRunning() && serial.baud == 19200); + for (unsigned cycle = 0; cycle < 100; ++cycle) { + bridge.end(); assert(!bridge.isRunning()); + bridge.begin(); assert(bridge.isRunning()); + } + bridge.end(); assert(!bridge.isRunning()); +} +''' + + +class RS232DriverLifecycleTests(unittest.TestCase): + def test_failed_pins_or_driver_start_never_report_running_and_retry_recovers(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + driver = (ROOT / "src/helpers/bridges/RS232Bridge.cpp").read_text(encoding="ascii") + base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text(encoding="ascii") + code = FIXTURE.replace("@RUNNING@", extract_braced(base, "bool BridgeBase::isRunning() const") + .replace("BridgeBase::", "").replace(" override", "")) + code = code.replace("@BEGIN@", extract_braced(driver, "void RS232Bridge::begin()")) + code = code.replace("@END@", extract_braced(driver, "void RS232Bridge::end()")) + with tempfile.TemporaryDirectory(prefix="rs232-driver-lifecycle-") as directory: + work = Path(directory) + source, binary = work / "main.cpp", work / "test" + source.write_text(code, encoding="ascii") + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rs232_mqtt_runtime.py b/test/test_rs232_mqtt_runtime.py new file mode 100644 index 000000000..53c95d412 --- /dev/null +++ b/test/test_rs232_mqtt_runtime.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Execute real UART/MQTT/ESP-NOW repeater lifecycle and CLI independently.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class RS232MQTTRuntimeTests(unittest.TestCase): + def test_independent_transports_route_retry_restart_and_survive_browser_ota(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") + observer = (ROOT / "src/helpers/CommonCLI_Observer.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") + flags = ["-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2", + "-DWITH_RS232_BRIDGE_RX=5", "-DWITH_RS232_BRIDGE_TX=6", + "-DRS232_BRIDGE_MERGED=1", "-DWITH_MQTT_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1", "-DWITH_BRIDGE=1", + "-DENV_INCLUDE_GPS=1", "-DLIGHTWEIGHT_WIFI_OTA=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1"] + def preprocess(source, extra=()): + return subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, *extra, "-"], + input=source, capture_output=True, text=True, check=True).stdout + processed_header = preprocess(re.sub(r'^\s*#include[^\n]*', '', header, flags=re.M)) + lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in ( + "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", + "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", + "bool isBridgeRunning() const override", "bool rs232BridgeEnabled() const", "bool isRs232BridgeRunning()", + "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()", + "bool startSharedEspNowBridgeIfReady()", "bool isEspNowBridgeRunning()", + "bool setEspNowBridgeState(bool enable)", "bool restartEspNowBridge()", + "bool isMqttBridgeRunning()", "bool setMqttBridgeState(bool enable)", + "bool restartMqttBridge()", "bool setBridgeState(bool enable)", "bool restartBridge()", + )) + defaults_start = implementation.index("// bridge defaults") + defaults_end = implementation.index("// GPS defaults", defaults_start) + boot_start = implementation.index('mesh::hilStartupTrace("mesh_bridge_begin");') + boot_start = implementation.index("#if defined(WITH_BRIDGE)", boot_start) + boot_end = implementation.index('mesh::hilStartupTrace("mesh_bridge_ready");', boot_start) + 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] + service_start = implementation.index("#if defined(WITH_ESPNOW_BRIDGE)", + implementation.index("MyMesh::servicePostMeshLoop()")) + service_end = implementation.index(" if (next_flood_advert", service_start) + pending_start = implementation.index("#if defined(WITH_BRIDGE)", + implementation.index("bool MyMesh::hasPendingWork()")) + pending_end = implementation.index(" if (radio_driver.isWatchdogObserving()", pending_start) + commands = preprocess("\n".join(extract_braced(cli, signature) for signature in ( + "void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd("))) + sets = "\nelse ".join(extract_braced(commands, signature) for signature in ( + 'if (strncmp(config, "mqtt.enabled ", 13) == 0)', + 'if (strncmp(config, "rs232.enabled ", 14) == 0)', + 'if (memcmp(config, "espnow.enabled ", 15) == 0)', + 'if (memcmp(config, "bridge.enabled ", 15) == 0)', + 'if (memcmp(config, "bridge.baud ", 12) == 0)', + 'if (memcmp(config, "bridge.uart ", 12) == 0)', + 'if (memcmp(config, "bridge.channel ", 15) == 0)', + 'if (memcmp(config, "bridge.secret ", 14) == 0)', + 'if (memcmp(config, "bridge.format ", 14) == 0)', + )) + gets = "\nelse ".join(extract_braced(commands, signature) for signature in ( + 'if (configKeyEquals(config, "bridge.type"))', + 'if (configKeyEquals(config, "rs232.enabled"))', + 'if (configKeyEquals(config, "rs232.running"))', + 'if (configKeyEquals(config, "bridge.enabled"))', + 'if (configKeyEquals(config, "bridge.running"))', + 'if (configKeyEquals(config, "espnow.enabled"))', + 'if (configKeyEquals(config, "espnow.running"))', + 'if (configKeyEquals(config, "bridge.baud"))', + 'if (configKeyEquals(config, "bridge.uart"))', + )) + gets += "\nelse " + "\nelse ".join(extract_braced(observer, signature) for signature in ( + 'if (strcmp(config, "mqtt.enabled") == 0)', 'if (strcmp(config, "mqtt.running") == 0)')) + ota_start = cli.index(' } else if (memcmp(command, "start ota", 9)') + ota_end = cli.index(' } else if (memcmp(command, "clock", 5)', ota_start) + code = (ROOT / "test/fixtures/rs232_mqtt_runtime.cpp").read_text(encoding="ascii") + for key, value in { + "LIFECYCLE": lifecycle, "DEFAULTS": implementation[defaults_start:defaults_end], + "FILTER": extract_braced(header, "void configureBridgeFilter(AbstractBridge* active_bridge)"), + "BOOT": implementation[boot_start:boot_end], "ROUTE_RX": routing["logRx"], + "ROUTE_TX": routing["logTx"], "SERVICE": implementation[service_start:service_end], + "PENDING": implementation[pending_start:pending_end], "SET_BRANCHES": sets, + "GET_BRANCHES": gets, "OTA": cli[ota_start:ota_end], + "PARSERS": "\n".join(extract_braced(cli, signature) for signature in ( + "static bool parseOnOffStrict(", "static bool configKeyEquals(")), + }.items(): + code = code.replace("@" + key + "@", value) + with tempfile.TemporaryDirectory(prefix="rs232-mqtt-runtime-") as temporary: + work = Path(temporary) + source, binary = work / "main.cpp", work / "test" + source.write_text(code, encoding="ascii") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *flags, "-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("Three transport runtime checks passed", result.stdout) + + def test_uart_and_mqtt_without_espnow_preserve_primary_mqtt_alias(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") + callback_header = (ROOT / "src/helpers/CommonCLI.h").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") + flags = ["-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2", + "-DWITH_RS232_BRIDGE_RX=5", "-DWITH_RS232_BRIDGE_TX=6", "-DRS232_BRIDGE_MERGED=1", + "-DWITH_MQTT_BRIDGE=1", "-DWITH_BRIDGE=1", "-DENV_INCLUDE_GPS=1", + "-DLIGHTWEIGHT_WIFI_OTA=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1"] + def preprocess(source): + return subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, "-"], + input=source, capture_output=True, text=True, check=True).stdout + processed_header = preprocess(re.sub(r'^\s*#include[^\n]*', '', header, flags=re.M)) + lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in ( + "AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {", + "RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()", + "bool rs232BridgeEnabled() const", "bool isBridgeRunning() const override", + "bool isRs232BridgeRunning()", "bool setRs232BridgeState(bool enable)", + "bool restartRs232Bridge()", "bool isMqttBridgeRunning()", "bool setBridgeState(bool enable)", + "bool restartBridge()")) + code = (ROOT / "test/fixtures/rs232_mqtt_runtime.cpp").read_text(encoding="ascii") + code = code[:code.index("int main() {")] + for signature in ("virtual bool isEspNowBridgeRunning() = 0;", + "virtual bool setEspNowBridgeState(bool) = 0;", + "virtual bool restartEspNowBridge() = 0;"): + code = code.replace(signature, "") + for short, full in (("virtual bool setMqttBridgeState(bool) = 0;", "virtual bool setMqttBridgeState(bool enable)"), + ("virtual bool restartMqttBridge() = 0;", "virtual bool restartMqttBridge()")): + code = code.replace(short, extract_braced(callback_header, full)) + commands = preprocess("\n".join(extract_braced(cli, signature) for signature in ( + "void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd("))) + boot_start = implementation.index('mesh::hilStartupTrace("mesh_bridge_begin");') + boot_start = implementation.index("#if defined(WITH_BRIDGE)", boot_start) + boot_end = implementation.index('mesh::hilStartupTrace("mesh_bridge_ready");', boot_start) + service_start = implementation.index("#if defined(WITH_ESPNOW_BRIDGE)", implementation.index("MyMesh::servicePostMeshLoop()")) + service_end = implementation.index(" if (next_flood_advert", service_start) + pending_start = implementation.index("#if defined(WITH_BRIDGE)", implementation.index("bool MyMesh::hasPendingWork()")) + pending_end = implementation.index(" if (radio_driver.isWatchdogObserving()", pending_start) + route = {} + for name in ("logRx", "logTx"): + start = implementation.index("#ifdef WITH_MQTT_BRIDGE\n", implementation.index("void MyMesh::" + name + "(")) + route[name] = implementation[start:implementation.index(" if (_logging)", start)] + ota_start = cli.index(' } else if (memcmp(command, "start ota", 9)') + defaults_start = implementation.index("// bridge defaults") + substitutions = { + "LIFECYCLE": lifecycle, "BOOT": implementation[boot_start:boot_end], + "DEFAULTS": implementation[defaults_start:implementation.index("// GPS defaults", defaults_start)], + "FILTER": extract_braced(header, "void configureBridgeFilter(AbstractBridge* active_bridge)"), + "ROUTE_RX": route["logRx"], "ROUTE_TX": route["logTx"], + "SERVICE": implementation[service_start:service_end], "PENDING": implementation[pending_start:pending_end], + "SET_BRANCHES": "\nelse ".join(extract_braced(commands, signature) for signature in ( + 'if (strncmp(config, "mqtt.enabled ", 13) == 0)', 'if (strncmp(config, "rs232.enabled ", 14) == 0)', + 'if (memcmp(config, "bridge.enabled ", 15) == 0)', 'if (memcmp(config, "bridge.baud ", 12) == 0)', + 'if (memcmp(config, "bridge.uart ", 12) == 0)')), + "GET_BRANCHES": extract_braced(commands, 'if (configKeyEquals(config, "bridge.type"))'), + "PARSERS": "\n".join(extract_braced(cli, signature) for signature in ( + "static bool parseOnOffStrict(", "static bool configKeyEquals(")), + "OTA": cli[ota_start:cli.index(' } else if (memcmp(command, "clock", 5)', ota_start)], + } + for key, value in substitutions.items(): + code = code.replace("@" + key + "@", value) + code += r'''int main() { + assert(millisHasNowPassed(millis())); + MyMesh mesh; mesh.defaults(); mesh.boot(); CommonCLI cli(mesh); + expect_get(cli, "bridge.type", "> mqtt+rs232"); + assert(mesh.isMqttBridgeRunning() && !mesh.isRs232BridgeRunning()); + char reply[256] = {}; + cli.set("rs232.enabled on", reply); assert(strcmp(reply, "OK") == 0); + assert(mesh.isRs232BridgeRunning() && mesh.isMqttBridgeRunning()); + const unsigned starts = mesh.mqtt_bridge->begins; + cli.set("bridge.baud 57600", reply); assert(strcmp(reply, "OK") == 0); + assert(mesh.isRs232BridgeRunning() && mesh.mqtt_bridge->begins == starts); + cli.set("bridge.enabled off", reply); assert(strcmp(reply, "OK") == 0); + assert(!mesh._prefs.bridge_enabled && mesh._prefs.rs232_bridge_enabled); + assert(!mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning()); + assert(mesh.isBridgeRunning() && mesh.bridgesPreventSleep()); + cli.set("bridge.enabled on", reply); assert(strcmp(reply, "OK") == 0); + assert(mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning()); + mesh::Packet packet; + mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges(); + assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1); + assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1); + assert(mesh.filter_checks == 3); + const unsigned uart_starts = Serial2.begins; + cli.run("start ota ap", reply); + assert(mesh._cli.board.ota && !mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning()); + cli.run("stop ota", reply); + assert(!mesh._cli.board.ota && mesh.isMqttBridgeRunning() && Serial2.begins == uart_starts); + cli.set("rs232.enabled off", reply); cli.set("mqtt.enabled off", reply); + assert(!mesh.bridge && !mesh.isMqttBridgeRunning() && !mesh.bridgesPreventSleep()); + cli.set("rs232.enabled on", reply); mesh.mqtt_bridge->fail_starts = 1; + cli.set("mqtt.enabled on", reply); + assert(strstr(reply, "setting saved") && !mesh.isMqttBridgeRunning()); + assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep()); + cli.set("mqtt.enabled on", reply); + assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeRunning()); +}''' + with tempfile.TemporaryDirectory(prefix="rs232-mqtt-only-") as directory: + work = Path(directory) + source, binary = work / "main.cpp", work / "test" + source.write_text(code, encoding="ascii") + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *flags, "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rs232_runtime.py b/test/test_rs232_runtime.py index a33dc36c1..cd10e51a2 100644 --- a/test/test_rs232_runtime.py +++ b/test/test_rs232_runtime.py @@ -35,6 +35,11 @@ class RS232RuntimeTests(unittest.TestCase): ("merged_composite", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", "RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"]), + ("mqtt_rs232", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1"]), + ("mqtt_rs232_espnow", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1", + "WITH_ESPNOW_BRIDGE=1"]), ("sole_espnow_merged", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"]), ("sole_espnow_dedicated", ["WITH_ESPNOW_BRIDGE=1"]), ("sole_espnow_default_on", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1", @@ -71,11 +76,21 @@ class RS232RuntimeTests(unittest.TestCase): code += (ROOT / "test/fixtures/rs232_runtime/prefs.cpp").read_text(encoding="ascii") for profile, macros in ( ("ordinary_unmerged", []), + ("rs232_dedicated", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2"]), + ("rs232_default_on", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]), + ("nrf52_rs232_default_on", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]), ("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"]), + ("mqtt_rs232", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1"]), + ("mqtt_rs232_espnow", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", + "RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1", + "WITH_ESPNOW_BRIDGE=1"]), ("sole_espnow_merged", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"]), ("sole_espnow_dedicated", ["WITH_ESPNOW_BRIDGE=1"]), ("sole_espnow_default_on", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1", @@ -89,7 +104,7 @@ class RS232RuntimeTests(unittest.TestCase): 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", + compiler, "-std=c++17", "-D" + ("NRF52_PLATFORM=1" if profile.startswith("nrf52") else "ESP32_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"), @@ -100,27 +115,82 @@ class RS232RuntimeTests(unittest.TestCase): result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_uart_intent_survives_cross_profile_upgrades_without_claiming_mqtt_intent(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + base = persistence_harness().replace("#define WITH_RS232_BRIDGE_UART 1", "") + code = base[:base.index("struct Capture {")] + code += (ROOT / "test/fixtures/rs232_runtime/prefs.cpp").read_text(encoding="ascii") + common = ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", "RS232_BRIDGE_MERGED=1"] + profiles = {"normal": [*common, "WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"], + "dedicated": common[:2], + "triple": [*common, "WITH_ESPNOW_BRIDGE=1", "WITH_MQTT_BRIDGE=1"], + "mqtt_uart": [*common, "WITH_MQTT_BRIDGE=1"]} + with tempfile.TemporaryDirectory(prefix="rs232-profile-upgrade-") 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 = work / "main.cpp" + source.write_text(code, encoding="ascii") + for name, macros in profiles.items(): + 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(work / name)], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def run(profile, operation, image, primary, espnow, uart, port=2): + result = subprocess.run([str(work / profile), operation, str(image), str(primary), + str(espnow), str(uart), str(port)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for origin in ("triple", "mqtt_uart"): + for uart in (0, 1): + image = work / f"{origin}-{uart}.bin" + run(origin, "write", image, 1, 0, uart) + self.assertEqual(len(image.read_bytes()), 877) + for target in ("normal", "dedicated"): + with self.subTest(origin=origin, target=target, uart=uart): + run(target, "read", image, uart, 0, uart) + # Old MQTT files persisted primary enabled plus UART2. + # Without the new independent marker this remains MQTT only. + old = work / "old-mqtt.bin" + old.write_bytes(image.read_bytes()[:874]) + run(origin, "read", old, 1, 0, 0) + for origin in ("normal", "dedicated"): + for uart in (0, 1): + image = work / f"{origin}-{uart}.bin" + run(origin, "write", image, uart, 0, uart) + for target in ("triple", "mqtt_uart"): + with self.subTest(origin=origin, target=target, uart=uart): + run(target, "read", image, uart, 0, uart) + image = work / "corrupt-triple.bin" + run("triple", "write", image, 1, 0, 1) + saved = image.read_bytes() + for tail in (saved[:875] + bytes([1]), saved[:876], saved[:876] + bytes([0])): + image.write_bytes(tail) + for target in ("normal", "dedicated"): + run(target, "read", image, 0, 0, 0) + for target in ("triple", "mqtt_uart"): + run(target, "read", image, 1, 0, 0) + 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")] + declaration_start = cli.index("#if defined(ESPNOW_BRIDGE_MERGED)", cli.index("mesh::hilStartupTrace(\"prefs_image_open_ready\")")) + declaration_end = cli.index(" // Every supported layout", declaration_start) + declaration = cli[declaration_start:declaration_end] 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")] + # The old image mock has just its UART byte, so marker reads return + # zero; execute the real marker guard rather than inventing a fallback. + marker_start = cli.index("uint8_t bridge_profile = 0;", cli.index("if (file.available() >= (int)sizeof(_prefs->ota_channel))")) + uart_read += "uint8_t bridge_profile = 0;\n" + extract_braced(cli[marker_start:], "if (file.read(&bridge_profile,") 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] @@ -137,7 +207,9 @@ class RS232RuntimeTests(unittest.TestCase): 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 endRS232Bridge()", "bool rs232BridgeEnabled() const", + "bool isRs232BridgeRunning() const override", "bool setRs232BridgeState(bool enable)", + "bool restartRs232Bridge()", "bool isBridgeRunning() const override", "bool setBridgeState(bool enable) override", "bool restartBridge() override", )) lifecycle += "\n#ifdef WITH_ESPNOW_BRIDGE\n" + "\n".join( diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index 2e0658752..a92161f01 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -37,7 +37,10 @@ STUBS = r''' #define WITH_ESPNOW_BRIDGE 1 #define MESH_DEBUG_PRINTLN(...) ((void)0) constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0; -constexpr int WIFI_AP = 2; +enum wifi_mode_t { WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2 }; +constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF; +int esp_wifi_stop(); +int esp_wifi_deinit(); constexpr int ESP_OK = 0; constexpr int LISTEN = 10; @@ -65,7 +68,20 @@ struct MockWiFi { IPAddress station_ip, ap_ip, configured_ap_ip; int ap_protocol = 15; int status() const { return station_status; } - int getMode() const { return sdk_ap_active ? WIFI_AP : 0; } + wifi_mode_t getMode() const { + return started_cache && sdk_started ? (sdk_ap_active ? WIFI_AP : WIFI_STA) : WIFI_OFF; + } + int channel() const { return initialized_cache && sdk_initialized ? 1 : 0; } + bool mode(wifi_mode_t requested) { + if (requested == getMode()) return true; + if (requested == WIFI_OFF) { + esp_wifi_stop(); esp_wifi_deinit(); + initialized_cache = started_cache = false; + } else { + sdk_initialized = sdk_started = initialized_cache = started_cache = true; + } + return true; + } IPAddress localIP() const { return station_ip; } IPAddress softAPIP() const { return ap_ip; } bool softAPConfig(IPAddress ip, IPAddress, IPAddress) { @@ -99,6 +115,11 @@ int esp_wifi_stop() { return 0; } int esp_wifi_deinit() { ++wifi_deinit_calls; sdk_initialized = false; return 0; } +int esp_wifi_get_mode(wifi_mode_t* mode) { + if (!sdk_initialized) return -1; + *mode = sdk_ap_active ? WIFI_AP : WIFI_STA; + return ESP_OK; +} int server_count = 0, server_begin_calls = 0; struct AsyncWebServerRequest { @@ -268,10 +289,12 @@ class WifiOtaLifecycleTests(unittest.TestCase): source = folder / "lifecycle.cpp" binary = folder / ("lifecycle.exe" if os.name == "nt" else "lifecycle") source.write_text(production_harness()) - subprocess.run([ + built = subprocess.run([ os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), str(source), "-o", str(binary), - ], check=True, capture_output=True, text=True, timeout=60) + ], capture_output=True, text=True, timeout=60) + if built.returncode: + raise RuntimeError(built.stdout + built.stderr) cls.observations = {} for scenario in SCENARIOS: result = subprocess.run([str(binary), scenario], check=True, @@ -317,7 +340,8 @@ class WifiOtaLifecycleTests(unittest.TestCase): self.assertEqual(observation["deinit_calls"], count) self.assertEqual(observation["sdk_initialized"], 1 - count) self.assertEqual(observation["sdk_started"], 1 - count) - self.assertTrue(observation["initialized_cache"] and observation["started_cache"]) + self.assertEqual(observation["initialized_cache"], 1 - count) + self.assertEqual(observation["started_cache"], 1 - count) self.assertEqual(observation["endpoint_usable"], 1 - count) if count == 0: if scenario == "cleanup_web": diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index e13d3f94a..8492fd452 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -136,6 +136,12 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 + ; Keep the normal repeater UART in the combined Full image; disabled until enabled. + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 + -D WITH_RS232_BRIDGE_RX=5 + -D WITH_RS232_BRIDGE_TX=6 + -D RS232_BRIDGE_MERGED=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 @@ -159,6 +165,7 @@ build_flags = -D ENV_PIN_SCL=34 build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + + @@ -587,6 +594,12 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 + ; Keep the normal repeater UART in the combined Full image; disabled until enabled. + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 + -D WITH_RS232_BRIDGE_RX=5 + -D WITH_RS232_BRIDGE_TX=6 + -D RS232_BRIDGE_MERGED=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 @@ -608,6 +621,7 @@ build_flags = # -D MQTT_PASSWORD='"your-password"' build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + + diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 2c176cd05..d521ae2c6 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -191,6 +191,12 @@ build_flags = -D MAX_NEIGHBOURS=50 -D MAX_RECENT_REPEATERS=256 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS -D WITH_MQTT_BRIDGE=1 + ; Keep the normal repeater UART in the combined Full image; disabled until enabled. + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 + -D WITH_RS232_BRIDGE_RX=34 + -D WITH_RS232_BRIDGE_TX=25 + -D RS232_BRIDGE_MERGED=1 -D WEBCONFIG_DISABLED=1 ; async portal exceeds this 4 MB target's dual-OTA app slot; use CLI setup -D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS -D MQTT_MAX_PACKET_SIZE=1024 @@ -209,6 +215,7 @@ build_flags = # -D MQTT_PASSWORD='"your-password"' build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter} + + + + + + diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index e26d8ab93..02f01a64d 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -121,6 +121,12 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 + ; Keep the normal repeater UART in the combined Full image; disabled until enabled. + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_UART=2 + -D WITH_RS232_BRIDGE_RX=5 + -D WITH_RS232_BRIDGE_TX=6 + -D RS232_BRIDGE_MERGED=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MQTT_MAX_PACKET_SIZE=1024 @@ -143,6 +149,7 @@ build_flags = -D ENV_PIN_SCL=18 build_src_filter = ${rak3112.build_src_filter} + + + + + +