From 30067cb5cd7e919363fb345661d49899f2c668d9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 17 Sep 2026 01:13:10 -0700 Subject: [PATCH] Split MQTT and ESP-NOW controls in Full ESP32 builds --- docs/_javascript/firmware_picker.js | 11 ++- docs/firmware_picker.md | 8 +- examples/InfrastructureWireless.h | 12 +-- examples/simple_repeater/MyMesh.cpp | 14 ++- examples/simple_repeater/MyMesh.h | 106 ++++++++++++++------ examples/simple_room_server/MyMesh.cpp | 14 ++- examples/simple_room_server/MyMesh.h | 131 +++++++++++++++++-------- src/helpers/CommonCLI.cpp | 91 +++++++++++++++-- src/helpers/CommonCLI.h | 14 +++ src/helpers/CommonCLI_Observer.cpp | 13 ++- test/test_cli_settings_contract.py | 3 + test/test_espnow_radio_lifecycle.py | 19 ++++ test/test_firmware_picker.js | 5 +- test/test_wireless_control.py | 4 + 14 files changed, 337 insertions(+), 108 deletions(-) diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index b600f2a8..2b2e1c9b 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1154,9 +1154,9 @@ ], "Separate from device sleep and LoRa RXPS. Bluetooth coexistence can constrain the effective mode."); } if (info.mqtt) { - toggle(info.espnowBridge ? "MQTT + ESP-NOW bridge" : "MQTT broker connections", "set mqtt.enabled", "get mqtt.enabled", + toggle("MQTT broker connections", "set mqtt.enabled", "get mqtt.enabled", info.espnowBridge - ? "This Full image has one shared bridge switch: On starts WiFi/MQTT and then ESP-NOW after WiFi associates; Off stops both. Configure WiFi and broker slots first, and set bridge.channel to your access point's fixed 2.4 GHz channel." + ? "Independent of the ESP-NOW bridge below. Configure WiFi and broker slots first. Use MQTT alone, ESP-NOW alone, or enable both; get mqtt.running checks the MQTT runtime." : "Configure WiFi and broker slots first, using WebConfig or set mqtt1.preset/settings. Off keeps those settings. get mqtt.running checks the runtime; get mqtt.status shows connections."); ["status", "packets", "raw", "rx"].forEach(function (name) { toggle("MQTT " + (name === "rx" ? "receive capture" : name + " publication"), "set mqtt." + name, "get mqtt." + name, @@ -1183,11 +1183,14 @@ }), "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) { - if (!info.mqtt) toggle("ESP-NOW bridge", "set bridge.enabled", "get bridge.running"); + toggle("ESP-NOW bridge", "set bridge.enabled", "get bridge.enabled", + info.mqtt + ? "Independent of MQTT in this Full image. set espnow.enabled is an alias. get bridge.running (or get espnow.running) checks its live state." + : "set bridge.enabled controls the ESP-NOW bridge; get bridge.running checks its live state."); section("ESP-NOW bridge framing", ["wrapped", "raw"].map(function (mode) { return { label: mode, commands: ["set bridge.format " + mode] }; }), info.mqtt - ? "This Full image shares one 2.4 GHz radio between WiFi/MQTT and ESP-NOW. ESP-NOW begins after WiFi connects and only when bridge.channel matches that access point's fixed channel. Primary ESP-NOW mesh uses set espnow.channel instead." + ? "This Full image shares one 2.4 GHz radio between WiFi/MQTT and ESP-NOW. When both are enabled, ESP-NOW begins after WiFi connects and only when bridge.channel matches that access point's fixed channel. ESP-NOW-only mode does not need WiFi credentials. Primary ESP-NOW mesh uses set espnow.channel instead." : "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels."); } if (full && /sensecapindicator/i.test(profile.target)) section("Indicator wireless transport", [ diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index fd471a7e..eeaadab0 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -180,8 +180,12 @@ For commands and option explanations, follow the An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Expanded ESP32 Full repeater and room-server images combine ESP-NOW with WiFi MQTT in the -same firmware. Both use one 2.4 GHz radio, so the ESP-NOW bridge channel must -match the connected WiFi access point's fixed channel. Its runtime +same firmware. MQTT and ESP-NOW are independent runtime transports: use +`set mqtt.enabled on|off` for MQTT and `set bridge.enabled on|off` (or +`set espnow.enabled on|off`) for ESP-NOW, so either or both can run. Both 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 backward-compatible bridge-to-bridge default using `bridge.secret`) or `raw` (direct MeshCore ESP-NOW LR frames for `Generic_ESPNOW`, diff --git a/examples/InfrastructureWireless.h b/examples/InfrastructureWireless.h index 7cc1a224..4d1bf469 100644 --- a/examples/InfrastructureWireless.h +++ b/examples/InfrastructureWireless.h @@ -26,10 +26,10 @@ public: if (the_mesh.isWebConfigActive()) mask |= mesh::wireless::WiFi; #endif #ifdef WITH_MQTT_BRIDGE - if (the_mesh.isBridgeRunning()) mask |= mesh::wireless::WiFi; + if (the_mesh.isMqttBridgeRunning()) mask |= mesh::wireless::WiFi; #endif #ifdef WITH_ESPNOW_BRIDGE - if (the_mesh.isBridgeRunning()) mask |= mesh::wireless::EspNow; + if (the_mesh.isEspNowBridgeRunning()) mask |= mesh::wireless::EspNow; #endif #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW if (radio_driver.isEnabled()) mask |= mesh::wireless::EspNow; @@ -61,12 +61,12 @@ public: resume_web_ = the_mesh.isWebConfigActive(); #endif #ifdef WITH_MQTT_BRIDGE - resume_mqtt_ = the_mesh.isBridgeRunning(); + resume_mqtt_ = the_mesh.isMqttBridgeRunning(); #endif wifi_saved_ = true; } #ifdef WITH_MQTT_BRIDGE - if (!the_mesh.setBridgeState(false)) return Result::Failed; + if (!the_mesh.setMqttBridgeState(false)) return Result::Failed; #endif #ifdef WITH_WEBCONFIG if (the_mesh.isWebConfigActive()) { @@ -102,7 +102,7 @@ public: || !(enabled() & mesh::wireless::WiFi) #endif ) { - if (!the_mesh.setBridgeState(true)) return Result::Failed; + if (!the_mesh.setMqttBridgeState(true)) return Result::Failed; } #endif wifi_saved_ = resume_web_ = resume_mqtt_ = false; @@ -111,7 +111,7 @@ public: #endif #ifdef WITH_ESPNOW_BRIDGE if (service == mesh::wireless::EspNow) - return the_mesh.setBridgeState(on) ? Result::Done : Result::Failed; + return the_mesh.setEspNowBridgeState(on) ? Result::Done : Result::Failed; #endif #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW if (service == mesh::wireless::EspNow) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a6aea8c5..bed83da0 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1146,7 +1146,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting - if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt); + 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; @@ -1198,7 +1198,7 @@ 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->sendPacket(pkt); + 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()) { @@ -3595,6 +3595,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #else _prefs.bridge_enabled = 1; // enabled #endif + _prefs.espnow_bridge_enabled = 1; // preserves the Full image's former combined default _prefs.bridge_delay = 500; // milliseconds _prefs.bridge_pkt_src = 1; // logRx (RX packets) _prefs.bridge_baud = 115200; // baud rate @@ -3792,6 +3793,10 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true); +#endif + // Wire fault-alert reporter. begin() is safe regardless of bridge state. // Passing `this` as the callbacks lets the reporter resolve a TransportKey // scope (alert.region override, falling back to default_scope) so alert @@ -3806,7 +3811,8 @@ void MyMesh::begin(FILESYSTEM *fs) { #ifdef WITH_MQTT_BRIDGE // Preserve the MQTT observer's first-boot setup experience even though the // persistent WebUI master switch defaults off on infrastructure roles. - start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0; + start_webui = start_webui || (_prefs.bridge_enabled + && _cli.getObserverPrefs()->wifi_ssid[0] == 0); if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); @@ -10814,7 +10820,7 @@ void MyMesh::onConfigBatchEnd() { if (_wc_restart_pending) { _wc_restart_pending = false; _wc_slot_restart_mask = 0; - if (_prefs.bridge_enabled) restartBridge(); + if (_prefs.bridge_enabled) restartMqttBridge(); return; } #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f2b05bb4..64a81848 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -604,7 +604,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks // up a second WiFi owner before the AP selects the shared channel. bool startSharedEspNowBridgeIfReady() { if (espnow_bridge.isRunning()) return true; - if (!mqtt_bridge || !mqtt_bridge->isRunning() || !WiFi.isConnected()) return false; + if (!_prefs.espnow_bridge_enabled) return false; + if (mqtt_bridge && mqtt_bridge->isRunning() && !WiFi.isConnected()) return false; if (!millisHasNowPassed(shared_espnow_retry_at)) return false; shared_espnow_retry_at = millis() + 5000; configureBridgeFilter(&espnow_bridge); @@ -1246,12 +1247,22 @@ public: #endif } - bool setBridgeState(bool enable) override { #if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - // The combined Full bridge is intentionally one saved switch. ESP-NOW is - // started only after MQTT has brought WiFi up, so it can validate and share - // the access point channel instead of retuning the station interface. - if (!enable && !mqtt_bridge && !espnow_bridge.isRunning()) return true; + bool isEspNowBridgeRunning() override { + return espnow_bridge.isRunning(); + } + + bool setMqttBridgeState(bool enable) override { + if (!enable) { + if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); + _alerter.setBridge(nullptr); + return !mqtt_bridge || !mqtt_bridge->isRunning(); + } + // Give the WiFi station to MQTT while it associates. ESP-NOW is restarted + // after the AP channel is known, which avoids a raw ESP-NOW-only session + // pinning the station to a different channel. + if (espnow_bridge.isRunning()) espnow_bridge.end(); + shared_espnow_retry_at = 0; if (!mqtt_bridge) { MQTTNodeInfo node_info; node_info.node_name = _prefs.node_name; @@ -1265,32 +1276,71 @@ public: getRTCClock(), &self_id); if (!mqtt_bridge) return false; } - if (enable) { - if (!mqtt_bridge->isRunning()) { - char device_id[65]; - mesh::LocalIdentity self_id = getSelfId(); - mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); - mqtt_bridge->setDeviceID(device_id); - mqtt_bridge->setFirmwareVersion(getFirmwareVer()); - mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); - mqtt_bridge->setBuildDate(getBuildDate()); - mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); - configureBridgeFilter(mqtt_bridge); - mqtt_bridge->begin(); - _alerter.setBridge(mqtt_bridge); - } - if (!mqtt_bridge->isRunning()) return false; + if (!mqtt_bridge->isRunning()) { + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + mqtt_bridge->setDeviceID(device_id); + mqtt_bridge->setFirmwareVersion(getFirmwareVer()); + mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + mqtt_bridge->setBuildDate(getBuildDate()); + mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + configureBridgeFilter(mqtt_bridge); + mqtt_bridge->begin(); + _alerter.setBridge(mqtt_bridge); + } + if (_prefs.espnow_bridge_enabled) startSharedEspNowBridgeIfReady(); + return mqtt_bridge->isRunning(); + } + + bool setEspNowBridgeState(bool enable) override { + if (!enable) { + if (espnow_bridge.isRunning()) espnow_bridge.end(); shared_espnow_retry_at = 0; + return !espnow_bridge.isRunning(); + } + if (espnow_bridge.isRunning()) return true; + shared_espnow_retry_at = 0; + 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(); - // MQTT has started successfully; ESP-NOW joins after its WiFi station - // associates and validates the configured shared channel. return true; } + configureBridgeFilter(&espnow_bridge); + espnow_bridge.begin(); + return espnow_bridge.isRunning(); + } + + bool restartMqttBridge() override { +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { + _wc_restart_pending = true; + return true; + } +#endif if (espnow_bridge.isRunning()) espnow_bridge.end(); - if (mqtt_bridge->isRunning()) mqtt_bridge->end(); + if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); shared_espnow_retry_at = 0; _alerter.setBridge(nullptr); - return !mqtt_bridge->isRunning() && !espnow_bridge.isRunning(); + return !_prefs.bridge_enabled || setMqttBridgeState(true); + } + + bool restartEspNowBridge() override { + if (espnow_bridge.isRunning()) espnow_bridge.end(); + shared_espnow_retry_at = 0; + return !_prefs.espnow_bridge_enabled || setEspNowBridgeState(true); + } +#endif + + bool setBridgeState(bool enable) override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (!enable) return setEspNowBridgeState(false) && setMqttBridgeState(false); + const bool mqtt_ok = _prefs.bridge_enabled + ? setMqttBridgeState(true) : setMqttBridgeState(false); + const bool espnow_ok = _prefs.espnow_bridge_enabled + ? setEspNowBridgeState(true) : setEspNowBridgeState(false); + return mqtt_ok && espnow_ok; #else // Disabling an already-absent heap-backed bridge is successful and must // not allocate an instance merely to stop it. The embedded ESP-NOW bridge @@ -1381,11 +1431,7 @@ public: return true; } #endif - if (espnow_bridge.isRunning()) espnow_bridge.end(); - if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); - shared_espnow_retry_at = 0; - _alerter.setBridge(nullptr); - return setBridgeState(true); + return restartEspNowBridge() && restartMqttBridge(); #else #ifdef WITH_RS232_BRIDGE // RS-232 changes must be applied synchronously so the CLI can commit or diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5bbe531f..ca8398ce 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -331,7 +331,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting - if (_prefs.bridge_enabled && bridge) bridge->onPacketReceived(pkt); + if (_prefs.bridge_enabled && bridge && bridge->isRunning()) bridge->onPacketReceived(pkt); #endif #ifdef WITH_ESPNOW_BRIDGE if (_prefs.bridge_pkt_src == 1 && espnow_bridge.isRunning()) { @@ -373,7 +373,7 @@ 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 (_prefs.bridge_enabled && bridge) bridge->sendPacket(pkt); + if (_prefs.bridge_enabled && bridge && bridge->isRunning()) bridge->sendPacket(pkt); #endif #ifdef WITH_ESPNOW_BRIDGE if (_prefs.bridge_pkt_src == 0 && espnow_bridge.isRunning()) { @@ -1321,6 +1321,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // bridge defaults (same as repeater) _prefs.bridge_enabled = 1; // enabled + _prefs.espnow_bridge_enabled = 1; // preserves the Full image's former combined default _prefs.bridge_delay = 500; // milliseconds _prefs.bridge_pkt_src = 1; // logRx (RX packets) _prefs.bridge_baud = 115200; // baud rate @@ -1458,6 +1459,10 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (_prefs.espnow_bridge_enabled) setEspNowBridgeState(true); +#endif + // Wire fault-alert reporter. begin() is safe regardless of bridge state. // Passing `this` as the callbacks lets the reporter resolve a TransportKey // scope (alert.region override, falling back to default_scope) so alert @@ -1470,7 +1475,8 @@ void MyMesh::begin(FILESYSTEM *fs) { #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) bool start_webui = WebConfigServer::loadEnabled(false); #ifdef WITH_MQTT_BRIDGE - start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0; + start_webui = start_webui || (_prefs.bridge_enabled + && _cli.getObserverPrefs()->wifi_ssid[0] == 0); if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); @@ -2008,7 +2014,7 @@ void MyMesh::onConfigBatchEnd() { if (_wc_restart_pending) { _wc_restart_pending = false; _wc_slot_restart_mask = 0; - if (_prefs.bridge_enabled) restartBridge(); + if (_prefs.bridge_enabled) restartMqttBridge(); return; } const uint8_t mask = _wc_slot_restart_mask; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 0828ad85..9d540ecd 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -564,7 +564,8 @@ public: // station interface so it can share the AP-selected channel safely. bool startSharedEspNowBridgeIfReady() { if (espnow_bridge.isRunning()) return true; - if (!bridge || !bridge->isRunning() || !WiFi.isConnected()) return false; + if (!_prefs.espnow_bridge_enabled) return false; + if (bridge && bridge->isRunning() && !WiFi.isConnected()) return false; if (!millisHasNowPassed(shared_espnow_retry_at)) return false; shared_espnow_retry_at = millis() + 5000; configureBridgeFilter(&espnow_bridge); @@ -581,20 +582,22 @@ public: #endif } - - void configureBridgeFilter(AbstractBridge* active_bridge) { -#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE - active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) { - return static_cast(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE - }, this); -#else - (void)active_bridge; -#endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + bool isEspNowBridgeRunning() override { + return espnow_bridge.isRunning(); } - bool setBridgeState(bool enable) override { -#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) - if (!enable && !bridge && !espnow_bridge.isRunning()) return true; + bool setMqttBridgeState(bool enable) override { + if (!enable) { + if (bridge && bridge->isRunning()) bridge->end(); + _alerter.setBridge(nullptr); + return !bridge || !bridge->isRunning(); + } + // Give the WiFi station to MQTT while it associates. ESP-NOW is restarted + // after the AP channel is known, which avoids a raw ESP-NOW-only session + // pinning the station to a different channel. + if (espnow_bridge.isRunning()) espnow_bridge.end(); + shared_espnow_retry_at = 0; if (!bridge) { MQTTNodeInfo node_info; node_info.node_name = _prefs.node_name; @@ -608,39 +611,87 @@ public: getRTCClock(), &self_id); if (!bridge) return false; } - if (enable) { - if (!bridge->isRunning()) { - char device_id[65]; - mesh::LocalIdentity self_id = getSelfId(); - mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); - bridge->setDeviceID(device_id); - bridge->setFirmwareVersion(getFirmwareVer()); - bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); - bridge->setBuildDate(getBuildDate()); - bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + if (!bridge->isRunning()) { + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #ifdef WITH_SNMP - if (_cli.getObserverPrefs()->snmp_enabled) { - _snmp_agent.setNodeName(_prefs.node_name); - _snmp_agent.setFirmwareVersion(getFirmwareVer()); - bridge->setSNMPAgent(&_snmp_agent); - } -#endif - configureBridgeFilter(bridge); - bridge->begin(); - _alerter.setBridge(bridge); + if (_cli.getObserverPrefs()->snmp_enabled) { + _snmp_agent.setNodeName(_prefs.node_name); + _snmp_agent.setFirmwareVersion(getFirmwareVer()); + bridge->setSNMPAgent(&_snmp_agent); } - if (!bridge->isRunning()) return false; +#endif + configureBridgeFilter(bridge); + bridge->begin(); + _alerter.setBridge(bridge); + } + if (_prefs.espnow_bridge_enabled) startSharedEspNowBridgeIfReady(); + return bridge->isRunning(); + } + + bool setEspNowBridgeState(bool enable) override { + if (!enable) { + if (espnow_bridge.isRunning()) espnow_bridge.end(); shared_espnow_retry_at = 0; + return !espnow_bridge.isRunning(); + } + if (espnow_bridge.isRunning()) return true; + shared_espnow_retry_at = 0; + if (bridge && bridge->isRunning()) { startSharedEspNowBridgeIfReady(); - // The MQTT task owns association. ESP-NOW starts after it sees the - // active AP channel and accepts the configured bridge channel. return true; } + configureBridgeFilter(&espnow_bridge); + espnow_bridge.begin(); + return espnow_bridge.isRunning(); + } + + bool restartMqttBridge() override { +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { + _wc_restart_pending = true; + return true; + } +#endif if (espnow_bridge.isRunning()) espnow_bridge.end(); - if (bridge->isRunning()) bridge->end(); + if (bridge && bridge->isRunning()) bridge->end(); shared_espnow_retry_at = 0; _alerter.setBridge(nullptr); - return !bridge->isRunning() && !espnow_bridge.isRunning(); + return !_prefs.bridge_enabled || setMqttBridgeState(true); + } + + bool restartEspNowBridge() override { + if (espnow_bridge.isRunning()) espnow_bridge.end(); + shared_espnow_retry_at = 0; + return !_prefs.espnow_bridge_enabled || setEspNowBridgeState(true); + } +#endif + + + void configureBridgeFilter(AbstractBridge* active_bridge) { +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE + active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) { + return static_cast(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE + }, this); +#else + (void)active_bridge; +#endif + } + + bool setBridgeState(bool enable) override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (!enable) return setEspNowBridgeState(false) && setMqttBridgeState(false); + const bool mqtt_ok = _prefs.bridge_enabled + ? setMqttBridgeState(true) : setMqttBridgeState(false); + const bool espnow_ok = _prefs.espnow_bridge_enabled + ? setEspNowBridgeState(true) : setEspNowBridgeState(false); + return mqtt_ok && espnow_ok; #else // An absent MQTT bridge is already stopped. Do not allocate one solely to // satisfy an idempotent disable request. @@ -705,11 +756,7 @@ public: return true; } #endif - if (espnow_bridge.isRunning()) espnow_bridge.end(); - if (bridge && bridge->isRunning()) bridge->end(); - shared_espnow_retry_at = 0; - _alerter.setBridge(nullptr); - return setBridgeState(true); + return restartEspNowBridge() && restartMqttBridge(); #else if (!bridge) return false; #ifdef WITH_WEBCONFIG diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c76a6a05..f0a17392 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1286,6 +1286,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { && preamble <= mesh::RadioProfiles::MaxPreamble))) { _prefs->primary_radio_preamble = preamble; } + if (file.available() >= (int)sizeof(_prefs->espnow_bridge_enabled)) { + file.read((uint8_t *)&_prefs->espnow_bridge_enabled, + sizeof(_prefs->espnow_bridge_enabled)); + } } } } @@ -1359,6 +1363,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { } #endif _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); + _prefs->espnow_bridge_enabled = constrain(_prefs->espnow_bridge_enabled, 0, 1); _prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000); _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); @@ -1607,6 +1612,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862 WRITE_COMMON_PREFS(&prefs->bridge_format); // 863 WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field + WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1764,6 +1770,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862 file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863 file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended + file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -3343,7 +3350,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->usb_logging_enabled = usb_enabled ? 1 : 0; _prefs->bridge_enabled = wifi_enabled ? 1 : 0; mesh::setUsbLoggingEnabled(usb_enabled); - _callbacks->setBridgeState(wifi_enabled); + _callbacks->setMqttBridgeState(wifi_enabled); savePrefs(); snprintf(reply, 160, "OK - logging.output %s (saved)", value); return; @@ -3392,6 +3399,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 (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); + } + return; + } +#endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) if (handleSdCardSetCmd(config, reply)) return; #endif @@ -4246,6 +4266,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (!parseOnOffStrict(&config[15], enable)) { strcpy(reply, "Error: usage set bridge.enabled on|off"); } else { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + // In a combined Full image bridge.enabled retains its ESP-NOW meaning; + // mqtt.enabled is the independent MQTT switch. + _prefs->espnow_bridge_enabled = enable; + const bool applied = _callbacks->setEspNowBridgeState(enable); + savePrefs(); + strcpy(reply, applied ? "OK" + : "Error: ESP-NOW runtime change failed; setting saved"); +#else #ifdef WITH_RS232_BRIDGE if (enable && _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart) @@ -4290,6 +4319,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, applied ? "OK" : "Error: bridge runtime change failed; setting saved"); +#endif #endif } } else if (memcmp(config, "bridge.delay ", 13) == 0) { @@ -4331,7 +4361,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep const uint32_t previous_baud = _prefs->bridge_baud; const bool previous_running = _callbacks->isBridgeRunning(); _prefs->bridge_baud = baud; - const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); + const bool applied = +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#else + !_prefs->bridge_enabled || _callbacks->restartBridge(); +#endif if (applied) { savePrefs(); strcpy(reply, "OK"); @@ -4374,7 +4409,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep const uint8_t previous_uart = _prefs->bridge_uart; const bool previous_running = _callbacks->isBridgeRunning(); _prefs->bridge_uart = (uint8_t)uart; - const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); + const bool applied = +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#else + !_prefs->bridge_enabled || _callbacks->restartBridge(); +#endif if (!applied) { _prefs->bridge_uart = previous_uart; const bool restored = previous_running @@ -4401,7 +4441,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (mesh::bridge::parseEspNowBridgeChannel( &config[15], _prefs->bridge_format, channel)) { _prefs->bridge_channel = channel; - const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); + const bool applied = +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#else + !_prefs->bridge_enabled || _callbacks->restartBridge(); +#endif savePrefs(); strcpy(reply, applied ? "OK" : "Error: setting saved; bridge failed to restart"); @@ -4416,7 +4461,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1)); } else { StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret)); - const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); + const bool applied = +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#else + !_prefs->bridge_enabled || _callbacks->restartBridge(); +#endif savePrefs(); strcpy(reply, applied ? "OK" : "Error: setting saved; bridge failed to restart"); @@ -4430,7 +4480,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: set bridge.channel to 1-13 before changing format"); } else { _prefs->bridge_format = format; - const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); + const bool applied = +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + !_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge(); +#else + !_prefs->bridge_enabled || _callbacks->restartBridge(); +#endif savePrefs(); if (!applied) { strcpy(reply, "Error: setting saved; bridge failed to restart"); @@ -4839,7 +4894,9 @@ 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", -#ifdef WITH_RS232_BRIDGE +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + "mqtt+espnow" +#elif defined(WITH_RS232_BRIDGE) "rs232" #elif WITH_ESPNOW_BRIDGE "espnow" @@ -4851,9 +4908,25 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep ); #ifdef WITH_BRIDGE } else if (configKeyEquals(config, "bridge.enabled")) { - sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off"); + sprintf(reply, "> %s", +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + _prefs->espnow_bridge_enabled ? "on" : "off"); +#else + _prefs->bridge_enabled ? "on" : "off"); +#endif } else if (configKeyEquals(config, "bridge.running")) { - sprintf(reply, "> %s", _callbacks->isBridgeRunning() ? "on" : "off"); + sprintf(reply, "> %s", +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + _callbacks->isEspNowBridgeRunning() ? "on" : "off"); +#else + _callbacks->isBridgeRunning() ? "on" : "off"); +#endif +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + } else if (configKeyEquals(config, "espnow.enabled")) { + sprintf(reply, "> %s", _prefs->espnow_bridge_enabled ? "on" : "off"); + } else if (configKeyEquals(config, "espnow.running")) { + sprintf(reply, "> %s", _callbacks->isEspNowBridgeRunning() ? "on" : "off"); +#endif } else if (configKeyEquals(config, "bridge.delay")) { sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay); } else if (configKeyEquals(config, "bridge.source")) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index a2947f74..bfe26f3a 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -191,6 +191,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 + // 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. + uint8_t espnow_bridge_enabled = 1; uint8_t retry_preset = 0; uint8_t direct_retry_attempts = 0; uint16_t direct_retry_base_ms = 0; @@ -574,6 +579,15 @@ 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 + // 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 restartMqttBridge() { return restartBridge(); } + virtual bool restartEspNowBridge() { return restartBridge(); } + virtual bool isEspNowBridgeRunning() { return isBridgeRunning(); } + virtual void restartBridgeSlot(int slot) { // Default: fall back to full restart restartBridge(); diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 8a7b39e4..11c2f58b 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -187,16 +187,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { strcpy(reply, "Error: use set mqtt.enabled on|off"); } else { - char canonical[24]; - snprintf(canonical, sizeof(canonical), "set bridge.enabled %s", value); - handleSetCmd(sender_timestamp, canonical, reply); + const bool enable = strcmp(value, "on") == 0; + _prefs->bridge_enabled = enable ? 1 : 0; + const bool applied = _callbacks->setMqttBridgeState(enable); + savePrefs(); + strcpy(reply, applied ? "OK" + : "Error: MQTT runtime change failed; setting saved"); } return true; } const auto restart_observer_bridge = [this]() { return mesh::cli::restartBridgeIfEnabled( _prefs->bridge_enabled != 0, - [this]() { return _callbacks->restartBridge(); }); + [this]() { return _callbacks->restartMqttBridge(); }); }; if (memcmp(config, "snmp.community ", 15) == 0) { if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true; @@ -887,7 +890,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf return true; } if (strcmp(config, "mqtt.running") == 0) { - snprintf(reply, 160, "> %s", _callbacks->isBridgeRunning() ? "on" : "off"); + snprintf(reply, 160, "> %s", _callbacks->isMqttBridgeRunning() ? "on" : "off"); return true; } if (memcmp(config, "snmp.community", 14) == 0) { diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 0a34e2fa..10876bda 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -48,6 +48,7 @@ def full_command_keys(text): 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', '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', @@ -80,6 +81,8 @@ class CLISettingsContractTest(unittest.TestCase): 'esp32_web': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'ADMIN_PASSWORD', 'MESH_USB_LOGGING_AVAILABLE'], 'esp32_mqtt': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE', 'WITH_BRIDGE', 'WITH_MQTT_NEIGHBORS', 'MESH_USB_LOGGING_AVAILABLE'], + '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'], 'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'], 'lr2021': ['USE_LR2021'], diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index fab7a7bb..463b250e 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -113,6 +113,25 @@ int main(){ ''' class EspNowLifecycleTests(unittest.TestCase): + def test_combined_full_transport_controls_are_independent(self): + common = (ROOT/'src/helpers/CommonCLI.cpp').read_text() + observer = (ROOT/'src/helpers/CommonCLI_Observer.cpp').read_text() + prefs = (ROOT/'src/helpers/CommonCLI.h').read_text() + self.assertIn('uint8_t espnow_bridge_enabled = 1;', prefs) + self.assertIn('setEspNowBridgeState(enable)', common) + self.assertIn('_prefs->espnow_bridge_enabled', common) + self.assertIn('setMqttBridgeState(enable)', observer) + self.assertIn('isMqttBridgeRunning()', observer) + for role in ('simple_repeater', 'simple_room_server'): + header = (ROOT/f'examples/{role}/MyMesh.h').read_text() + combined = method(header, 'bool setBridgeState(bool enable) override') + self.assertIn('setMqttBridgeState(true)', combined) + self.assertIn('setEspNowBridgeState(true)', combined) + mqtt = method(header, 'bool setMqttBridgeState(bool enable) override') + espnow = method(header, 'bool setEspNowBridgeState(bool enable) override') + self.assertIn('if (espnow_bridge.isRunning()) espnow_bridge.end();', mqtt) + self.assertIn('if (mqtt_bridge' if role == 'simple_repeater' else 'if (bridge', espnow) + def test_sdk_failures_stop_and_restart(self): header=(ROOT/'src/helpers/esp32/ESPNOWRadio.h').read_text() source=(ROOT/'src/helpers/esp32/ESPNOWRadio.cpp').read_text() diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index d6b8daf4..698330ee 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1012,9 +1012,10 @@ const combinedObserver = combinedCatalog.profiles.find(profile => profile.target assert.deepStrictEqual(picker.profileFieldValues(combinedObserver, 'mode'), ['standard', 'espnow']); assert(picker.profileMatches(combinedObserver, {mode: 'espnow'}, ['mode'])); const combinedDirections = picker.runtimeDirections(combinedObserver, {logging: 'usb'}); -assert(combinedDirections.some(section => section.title === 'MQTT + ESP-NOW bridge')); -assert(!combinedDirections.some(section => section.title === 'ESP-NOW bridge')); +assert(combinedDirections.some(section => section.title === 'MQTT broker connections')); +assert(combinedDirections.some(section => section.title === 'ESP-NOW bridge')); assert.strictEqual(commands(combinedDirections).filter(command => command === 'set mqtt.enabled on').length, 1); +assert.strictEqual(commands(combinedDirections).filter(command => command === 'set bridge.enabled on').length, 1); console.log('current release metadata and capacity directions tests passed'); assert.strictEqual(nrf.chipFamily, 'nrf52'); diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py index f6fea681..3d228830 100644 --- a/test/test_wireless_control.py +++ b/test/test_wireless_control.py @@ -338,6 +338,10 @@ struct { bool hasWirelessNetworkClient(){return network;} bool isBridgeRunning(){return bridge;} bool setBridgeState(bool on){bridge=on;return true;} + bool isMqttBridgeRunning(){return bridge;} + bool isEspNowBridgeRunning(){return bridge;} + bool setMqttBridgeState(bool on){bridge=on;return true;} + bool setEspNowBridgeState(bool on){bridge=on;return true;} bool stopWebConfig(char*){web=false;stopping=true;return true;} bool startWebConfig(bool,char*){web=true;return true;} } the_mesh;