Combine MKE S3 repeater bridge modes at runtime

This commit is contained in:
mikecarper committed 2026-10-05 17:12:10 -07:00
1 parent 0e0faa33a4
commit 6cd1289c8b
21 files changed
+1557 -55

No files matched your search

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