From 39dbc8bc7bd987f412510abc445528fdb3d13be6 Mon Sep 17 00:00:00 2001
From: Funkelfetisch <40317630+Funkelfetisch@users.noreply.github.com>
Date: Fri, 10 Jul 2026 12:28:02 +0300
Subject: [PATCH 1/4] Add universal BLE WiFi provisioning
---
docs/moonmodules/core/system.md | 10 +
esp32/main/CMakeLists.txt | 1 +
esp32/main/Kconfig.projbuild | 10 +
esp32/main/idf_component.yml | 9 +
esp32/sdkconfig.defaults.ble-provisioning | 11 +
moondeck/build/build_esp32.py | 12 +-
src/core/BleProvisioningModule.h | 93 ++++++++
src/main.cpp | 16 ++
src/platform/desktop/platform_config.h | 3 +
src/platform/desktop/platform_desktop.cpp | 13 ++
src/platform/esp32/platform_config.h | 6 +
.../esp32/platform_esp32_ble_provisioning.cpp | 213 ++++++++++++++++++
src/platform/platform.h | 12 +
13 files changed, 404 insertions(+), 5 deletions(-)
create mode 100644 esp32/sdkconfig.defaults.ble-provisioning
create mode 100644 src/core/BleProvisioningModule.h
create mode 100644 src/platform/esp32/platform_esp32_ble_provisioning.cpp
diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md
index 6227eda0..ab6fc228 100644
--- a/docs/moonmodules/core/system.md
+++ b/docs/moonmodules/core/system.md
@@ -51,6 +51,16 @@ Serial/BLE Improv Wi-Fi provisioning — the web installer hands credentials to
Detail: [technical](moxygen/ImprovProvisioningModule.md)
+
+
+### BLE provisioning
+
+Espressif BLE Wi-Fi provisioning for phone-app onboarding. The service accepts credentials locally and hands them to Network, which owns connection, persistence, and AP fallback.
+
+- `ble_status` — read-only provisioning state and BLE service name.
+
+Detail: [technical](moxygen/BleProvisioningModule.md)
+
### Devices
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 3fdea6e3..36099b06 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -20,6 +20,7 @@ idf_component_register(
"../../src/platform/esp32/platform_esp32_fs.cpp"
"../../src/platform/esp32/platform_esp32_ota.cpp"
"../../src/platform/esp32/platform_esp32_improv.cpp"
+ "../../src/platform/esp32/platform_esp32_ble_provisioning.cpp"
"../../src/platform/esp32/platform_esp32_rmt.cpp"
"../../src/platform/esp32/platform_esp32_tasks.cpp"
"../../src/platform/esp32/platform_esp32_gpio.cpp"
diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild
index f8833387..dff5d8f8 100644
--- a/esp32/main/Kconfig.projbuild
+++ b/esp32/main/Kconfig.projbuild
@@ -22,4 +22,14 @@ menu "projectMM"
path on an eth-only build (link up but no IP). Only the WiFi build
(esp32p4-eth-wifi) sets this, via sdkconfig.defaults.esp32p4-eth-wifi.
+ config MM_BLE_PROVISIONING
+ bool "Enable BLE WiFi provisioning"
+ default n
+ help
+ Pulls Espressif's network_provisioning component and enables its
+ BLE GATT transport for native-radio WiFi firmwares. The service
+ accepts WiFi credentials from Espressif-compatible phone apps, then
+ hands successful credentials to projectMM's NetworkModule so the
+ normal persistence and fallback flow remains authoritative.
+
endmenu
diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml
index 7a27dd59..c88356ae 100644
--- a/esp32/main/idf_component.yml
+++ b/esp32/main/idf_component.yml
@@ -5,6 +5,15 @@ dependencies:
version: "^1.16.0"
improv/improv:
version: "^1.2.5"
+ # Espressif's standard phone-app provisioning protocol over BLE. Optional and
+ # Kconfig-gated because Bluetooth/NimBLE costs flash + RAM and is meaningful
+ # only on native-radio WiFi firmware variants. Kconfig gate uses the
+ # `$CONFIG{NAME}` form (NO `CONFIG_` prefix inside the braces), matching the
+ # component-manager rule parser.
+ espressif/network_provisioning:
+ version: "^1.2.4"
+ rules:
+ - if: "$CONFIG{MM_BLE_PROVISIONING} == True"
# IP101 Ethernet PHY for the ESP32-P4-NANO. IDF v6 moved the per-PHY drivers
# out of esp_eth core into managed components; only referenced on the P4 build
# (ethInit's IP101 ctor is behind `#ifdef CONFIG_IDF_TARGET_ESP32P4`; the
diff --git a/esp32/sdkconfig.defaults.ble-provisioning b/esp32/sdkconfig.defaults.ble-provisioning
new file mode 100644
index 00000000..f915228a
--- /dev/null
+++ b/esp32/sdkconfig.defaults.ble-provisioning
@@ -0,0 +1,11 @@
+# Standard BLE WiFi provisioning via Espressif network_provisioning.
+CONFIG_MM_BLE_PROVISIONING=y
+
+# network_provisioning's BLE transport runs on NimBLE.
+CONFIG_BT_ENABLED=y
+CONFIG_BT_NIMBLE_ENABLED=y
+CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI=y
+
+# Use protocomm Security 1 (X25519 + PoP + AES-CTR), with a per-device PoP
+# derived from the MAC suffix at runtime.
+CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_1=y
diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py
index 0e11d601..4f809c82 100644
--- a/moondeck/build/build_esp32.py
+++ b/moondeck/build/build_esp32.py
@@ -131,7 +131,8 @@ def check_idf_pin(idf_path: Path) -> None:
# replaces the old separate `esp32` (WiFi-only) + `esp32-eth-wifi` keys.
"esp32": {
"chip": "esp32",
- "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.eth"],
+ "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.eth",
+ "sdkconfig.defaults.ble-provisioning"],
"eth_only": False,
"description": "ESP32 classic — WiFi + Ethernet (RMII; per-board pins/PHY "
"from deviceModels.json, default LAN8720 pins).",
@@ -140,7 +141,7 @@ def check_idf_pin(idf_path: Path) -> None:
"esp32-16mb": {
"chip": "esp32",
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.16mb",
- "sdkconfig.defaults.eth"],
+ "sdkconfig.defaults.eth", "sdkconfig.defaults.ble-provisioning"],
"eth_only": False,
"description": "ESP32 classic with 16 MB flash — WiFi + Ethernet. Same silicon "
"as `esp32`; this variant uses the extra flash for bigger OTA "
@@ -159,7 +160,7 @@ def check_idf_pin(idf_path: Path) -> None:
"esp32s3-n16r8": {
"chip": "esp32s3",
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32s3-n16r8",
- "sdkconfig.defaults.eth-spi"],
+ "sdkconfig.defaults.eth-spi", "sdkconfig.defaults.ble-provisioning"],
"eth_only": False,
"description": "ESP32-S3 DevKitC-1 (N16R8: 16 MB flash, 8 MB octal PSRAM) — WiFi + "
"W5500 SPI Ethernet (external module, pins per board in deviceModels.json)",
@@ -168,7 +169,7 @@ def check_idf_pin(idf_path: Path) -> None:
"esp32s3-n8r8": {
"chip": "esp32s3",
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32s3-n8r8",
- "sdkconfig.defaults.eth-spi"],
+ "sdkconfig.defaults.eth-spi", "sdkconfig.defaults.ble-provisioning"],
"eth_only": False,
"description": "ESP32-S3 (N8R8: 8 MB flash, 8 MB octal PSRAM) — WiFi + W5500 SPI "
"Ethernet. Half the flash of N16R8; the N16R8 binary overruns an "
@@ -204,7 +205,8 @@ def check_idf_pin(idf_path: Path) -> None:
},
"esp32s31": {
"chip": "esp32s31",
- "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32s31"],
+ "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32s31",
+ "sdkconfig.defaults.ble-provisioning"],
"eth_only": False,
"description": "Espressif ESP32-S31 Function-CoreBoard-1 — WiFi 6 + 1 Gbps "
"Ethernet LED control (RISC-V, 16 MB flash, PSRAM). The on-chip "
diff --git a/src/core/BleProvisioningModule.h b/src/core/BleProvisioningModule.h
new file mode 100644
index 00000000..9aa922b2
--- /dev/null
+++ b/src/core/BleProvisioningModule.h
@@ -0,0 +1,93 @@
+#pragma once
+
+#include "core/MoonModule.h"
+#include "core/NetworkModule.h"
+#include "core/SystemModule.h"
+#include "core/build_info.h"
+#include "platform/platform.h"
+
+#include
+#include
+#include
+
+namespace mm {
+
+/// Standard Espressif BLE WiFi provisioning. The BLE transport handles the
+/// local credential exchange; this module publishes accepted credentials back
+/// into NetworkModule so persistence, STA retry, and AP fallback stay in one
+/// place.
+class BleProvisioningModule : public MoonModule {
+public:
+ void setSystemModule(SystemModule* s) { systemModule_ = s; }
+ void setNetworkModule(NetworkModule* n) { networkModule_ = n; }
+
+ bool respectsEnabled() const override { return false; }
+ bool userEditable() const override { return false; }
+
+ void setup() override {
+ tryStart();
+ }
+
+ void onBuildControls() override {
+ setStatus(statusStr_);
+ controls_.addReadOnly("ble_status", statusStr_, sizeof(statusStr_));
+ }
+
+ void loop1s() override {
+ if (pendingCredentials_.load(std::memory_order_acquire) && networkModule_) {
+ networkModule_->setWifiCredentials(pendingSsid_, pendingPassword_);
+ std::memset(pendingPassword_, 0, sizeof(pendingPassword_));
+ pendingCredentials_.store(false, std::memory_order_release);
+ }
+
+ if constexpr (platform::hasBleProvisioning) {
+ if (!started_) {
+ const uint32_t now = platform::millis();
+ if (now - lastStartAttemptMs_ >= kRetryMs) {
+ tryStart();
+ }
+ }
+ }
+ }
+
+ void teardown() override {
+ if constexpr (platform::hasBleProvisioning) {
+ platform::bleProvisioningStop();
+ }
+ }
+
+private:
+ void tryStart() {
+ if constexpr (platform::hasBleProvisioning) {
+ lastStartAttemptMs_ = platform::millis();
+ const char* deviceName = systemModule_ ? systemModule_->deviceName() : "projectMM";
+ platform::ImprovDeviceInfo info{
+ deviceName,
+ platform::chipModel(),
+ kVersion,
+ };
+ started_ = platform::bleProvisioningInit(
+ info,
+ pendingSsid_, sizeof(pendingSsid_),
+ pendingPassword_, sizeof(pendingPassword_),
+ &pendingCredentials_,
+ statusStr_, sizeof(statusStr_));
+ } else {
+ std::strncpy(statusStr_, "not supported on this platform", sizeof(statusStr_) - 1);
+ started_ = false;
+ }
+ }
+
+ static constexpr uint32_t kRetryMs = 5000;
+
+ SystemModule* systemModule_ = nullptr;
+ NetworkModule* networkModule_ = nullptr;
+ char statusStr_[96] = "BLE provisioning starting";
+ char pendingSsid_[33] = {};
+ char pendingPassword_[64] = {};
+ std::atomic pendingCredentials_{false};
+ uint32_t lastStartAttemptMs_ = 0;
+ bool started_ = false;
+};
+
+} // namespace mm
diff --git a/src/main.cpp b/src/main.cpp
index 6b8a5fca..7c7f1d55 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -104,6 +104,7 @@
#include "core/IrService.h"
#include "core/FileManagerModule.h"
#include "core/FirmwareUpdateModule.h"
+#include "core/BleProvisioningModule.h"
#include "core/ImprovProvisioningModule.h"
#include "core/MqttModule.h"
#include "core/DevicesModule.h"
@@ -223,6 +224,7 @@ static void registerModuleTypes() {
mm::ModuleFactory::registerType("IrService", "core/services.md#ir");
mm::ModuleFactory::registerType("FileManagerModule", "core/system.md#file-manager");
mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/system.md#firmware-update");
+ mm::ModuleFactory::registerType("BleProvisioningModule", "core/system.md#ble-provisioning");
mm::ModuleFactory::registerType("ImprovProvisioningModule", "core/system.md#improv-provisioning");
mm::ModuleFactory::registerType("MqttModule", "core/system.md#mqtt");
mm::ModuleFactory::registerType("DevicesModule", "core/system.md#devices");
@@ -362,6 +364,19 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) {
improvModule->markWiredByCode();
}
+ // BLE provisioning uses Espressif's standard phone-app protocol. It is a
+ // Network child for the same reason as Improv: the transport publishes
+ // credentials; NetworkModule owns persistence and connection state.
+ mm::BleProvisioningModule* bleProvisioningModule = nullptr;
+ if constexpr (mm::platform::hasBleProvisioning) {
+ bleProvisioningModule = static_cast(
+ mm::ModuleFactory::create("BleProvisioningModule"));
+ bleProvisioningModule->setName("BLE Provision");
+ bleProvisioningModule->setSystemModule(systemModule);
+ bleProvisioningModule->setNetworkModule(networkModule);
+ bleProvisioningModule->markWiredByCode();
+ }
+
// MQTT service: a code-wired child of Network (like Improv), bridging the light controls to a
// broker for Homebridge/Home-Assistant. Built on every networked target (it uses TCP, so it
// works over WiFi or Ethernet); disabled until the user sets a broker. systemModule is injected
@@ -458,6 +473,7 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) {
scheduler.addModule(fileManagerModule);
scheduler.addModule(firmwareUpdateModule);
if (improvModule) networkModule->addChild(improvModule);
+ if (bleProvisioningModule) networkModule->addChild(bleProvisioningModule);
if (mqttModule) networkModule->addChild(mqttModule);
// Devices: discovers other devices on the LAN. Child of Network (discovery
// depends on the network being up); wired-by-code so persistence preserves it
diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h
index d83335f3..5340100b 100644
--- a/src/platform/desktop/platform_config.h
+++ b/src/platform/desktop/platform_config.h
@@ -75,6 +75,9 @@ constexpr bool hasOta = false;
// listener-install on desktop.
constexpr bool hasImprov = false;
+// BLE provisioning is an ESP32 radio feature. Desktop keeps the seam stubbed.
+constexpr bool hasBleProvisioning = false;
+
} // namespace mm::platform
// MM_MOONLIVE_HAS_HOST_JIT — 1 when this desktop host has BOTH the emit blob AND the general
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 3c9ec1f2..661f94d0 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -762,6 +762,19 @@ bool improvProvisioningInit(const ImprovDeviceInfo& /*info*/,
return false;
}
+bool bleProvisioningInit(const ImprovDeviceInfo& /*info*/,
+ char* /*ssidOut*/, size_t /*ssidOutLen*/,
+ char* /*passwordOut*/, size_t /*passwordOutLen*/,
+ std::atomic* /*ready*/,
+ char* statusBuf, size_t statusBufLen) {
+ if (statusBuf && statusBufLen > 0) {
+ std::snprintf(statusBuf, statusBufLen, "unsupported on desktop");
+ }
+ return false;
+}
+
+void bleProvisioningStop() {}
+
void reboot() {
// Desktop: the device is the host process. Exit cleanly; the OS user / supervisor
// can restart it. Matches the "device disappeared from the network" semantics the
diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h
index 7c8a7db0..c68f6f33 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -269,6 +269,12 @@ constexpr bool hasOta = true;
// (no serial peripheral) leaves it false.
constexpr bool hasImprov = true;
+#if defined(CONFIG_MM_BLE_PROVISIONING) && defined(CONFIG_BT_ENABLED) && !defined(MM_NO_WIFI) && !defined(CONFIG_IDF_TARGET_ESP32P4)
+constexpr bool hasBleProvisioning = true;
+#else
+constexpr bool hasBleProvisioning = false;
+#endif
+
} // namespace mm::platform
// MM_MOONLIVE_HAS_HOST_JIT — always 0 on ESP32. The unit tests that consume this macro run on
diff --git a/src/platform/esp32/platform_esp32_ble_provisioning.cpp b/src/platform/esp32/platform_esp32_ble_provisioning.cpp
new file mode 100644
index 00000000..a19ec5d6
--- /dev/null
+++ b/src/platform/esp32/platform_esp32_ble_provisioning.cpp
@@ -0,0 +1,213 @@
+#include "platform/platform.h"
+
+#include
+#include
+#include
+#include
+
+#if defined(CONFIG_MM_BLE_PROVISIONING) && defined(CONFIG_BT_ENABLED) && !defined(MM_NO_WIFI) && !defined(CONFIG_IDF_TARGET_ESP32P4)
+#include "esp_err.h"
+#include "esp_log.h"
+#include "network_provisioning/manager.h"
+#include "network_provisioning/scheme_ble.h"
+#endif
+
+namespace mm::platform {
+
+#if defined(CONFIG_MM_BLE_PROVISIONING) && defined(CONFIG_BT_ENABLED) && !defined(MM_NO_WIFI) && !defined(CONFIG_IDF_TARGET_ESP32P4)
+
+namespace {
+
+constexpr const char* TAG = "mm-ble-prov";
+
+struct BleProvisioningState {
+ char serviceName[32] = {};
+ char pop[8] = {};
+ char pendingSsid[33] = {};
+ char pendingPassword[64] = {};
+ char* ssidOut = nullptr;
+ size_t ssidOutLen = 0;
+ char* passwordOut = nullptr;
+ size_t passwordOutLen = 0;
+ std::atomic* ready = nullptr;
+ char* statusBuf = nullptr;
+ size_t statusBufLen = 0;
+ bool initialized = false;
+ bool started = false;
+};
+
+BleProvisioningState g_bleProv;
+
+void setStatus(const char* fmt, ...) {
+ if (!g_bleProv.statusBuf || g_bleProv.statusBufLen == 0) return;
+ va_list ap;
+ va_start(ap, fmt);
+ std::vsnprintf(g_bleProv.statusBuf, g_bleProv.statusBufLen, fmt, ap);
+ va_end(ap);
+}
+
+void copyBounded(char* dst, size_t dstLen, const uint8_t* src, size_t srcCap) {
+ if (!dst || dstLen == 0) return;
+ size_t n = 0;
+ while (n + 1 < dstLen && n < srcCap && src[n] != 0) {
+ dst[n] = static_cast(src[n]);
+ n++;
+ }
+ dst[n] = 0;
+}
+
+void copyPendingToOutputs() {
+ if (!g_bleProv.ssidOut || !g_bleProv.passwordOut || !g_bleProv.ready) return;
+ std::snprintf(g_bleProv.ssidOut, g_bleProv.ssidOutLen, "%s", g_bleProv.pendingSsid);
+ std::snprintf(g_bleProv.passwordOut, g_bleProv.passwordOutLen, "%s", g_bleProv.pendingPassword);
+ g_bleProv.ready->store(true, std::memory_order_release);
+}
+
+void makeIdentity(const ImprovDeviceInfo& info) {
+ uint8_t mac[6] = {};
+ getMacAddress(mac);
+
+ const char* name = (info.name && info.name[0]) ? info.name : "projectMM";
+ std::snprintf(g_bleProv.serviceName, sizeof(g_bleProv.serviceName),
+ "%.20s-%02X%02X%02X", name, mac[3], mac[4], mac[5]);
+ std::snprintf(g_bleProv.pop, sizeof(g_bleProv.pop),
+ "%02X%02X%02X", mac[3], mac[4], mac[5]);
+}
+
+void bleProvEventCb(void* /*user_data*/, network_prov_cb_event_t event, void* event_data) {
+ switch (event) {
+ case NETWORK_PROV_START:
+ setStatus("BLE: %s PoP %s", g_bleProv.serviceName, g_bleProv.pop);
+ ESP_LOGI(TAG, "BLE provisioning started, service=%s pop=%s",
+ g_bleProv.serviceName, g_bleProv.pop);
+ break;
+ case NETWORK_PROV_WIFI_CRED_RECV: {
+ auto* cfg = static_cast(event_data);
+ if (!cfg) break;
+ copyBounded(g_bleProv.pendingSsid, sizeof(g_bleProv.pendingSsid),
+ cfg->ssid, sizeof(cfg->ssid));
+ copyBounded(g_bleProv.pendingPassword, sizeof(g_bleProv.pendingPassword),
+ cfg->password, sizeof(cfg->password));
+ setStatus("BLE credentials received: %s", g_bleProv.pendingSsid);
+ ESP_LOGI(TAG, "BLE credentials received for SSID %s", g_bleProv.pendingSsid);
+ break;
+ }
+ case NETWORK_PROV_WIFI_CRED_SUCCESS:
+ copyPendingToOutputs();
+ setStatus("BLE connected: %s", g_bleProv.pendingSsid);
+ ESP_LOGI(TAG, "BLE provisioning connected: %s", g_bleProv.pendingSsid);
+ break;
+ case NETWORK_PROV_WIFI_CRED_FAIL: {
+ const auto* reason = static_cast(event_data);
+ const bool auth = reason && *reason == NETWORK_PROV_WIFI_STA_AUTH_ERROR;
+ setStatus("BLE error: %s", auth ? "auth failed" : "AP not found");
+ std::memset(g_bleProv.pendingPassword, 0, sizeof(g_bleProv.pendingPassword));
+ network_prov_mgr_reset_wifi_sm_state_on_failure();
+ ESP_LOGW(TAG, "BLE provisioning failed: %s", auth ? "auth failed" : "AP not found");
+ break;
+ }
+ case NETWORK_PROV_END:
+ if (!g_bleProv.ready || !g_bleProv.ready->load(std::memory_order_acquire)) {
+ setStatus("BLE provisioning stopped");
+ }
+ network_prov_mgr_deinit();
+ g_bleProv.started = false;
+ g_bleProv.initialized = false;
+ break;
+ case NETWORK_PROV_DEINIT:
+ g_bleProv.started = false;
+ g_bleProv.initialized = false;
+ break;
+ default:
+ break;
+ }
+}
+
+} // namespace
+
+bool bleProvisioningInit(const ImprovDeviceInfo& info,
+ char* ssidOut, size_t ssidOutLen,
+ char* passwordOut, size_t passwordOutLen,
+ std::atomic* ready,
+ char* statusBuf, size_t statusBufLen) {
+ if (g_bleProv.started) return true;
+ if (!ssidOut || ssidOutLen == 0 || !passwordOut || passwordOutLen == 0 || !ready) {
+ return false;
+ }
+
+ g_bleProv.ssidOut = ssidOut;
+ g_bleProv.ssidOutLen = ssidOutLen;
+ g_bleProv.passwordOut = passwordOut;
+ g_bleProv.passwordOutLen = passwordOutLen;
+ g_bleProv.ready = ready;
+ g_bleProv.statusBuf = statusBuf;
+ g_bleProv.statusBufLen = statusBufLen;
+ makeIdentity(info);
+
+ network_prov_mgr_config_t config = {};
+ config.scheme = network_prov_scheme_ble;
+ config.scheme_event_handler = NETWORK_PROV_SCHEME_BLE_EVENT_HANDLER_FREE_BTDM;
+ config.app_event_handler = network_prov_event_handler_t{
+ .event_cb = bleProvEventCb,
+ .user_data = nullptr,
+ };
+ config.network_prov_wifi_conn_cfg = network_prov_wifi_conn_cfg_t{
+ .wifi_conn_attempts = 3,
+ };
+
+ esp_err_t err = network_prov_mgr_init(config);
+ if (err != ESP_OK) {
+ setStatus("BLE init failed: %s", esp_err_to_name(err));
+ ESP_LOGW(TAG, "network_prov_mgr_init failed: %s", esp_err_to_name(err));
+ return false;
+ }
+ g_bleProv.initialized = true;
+
+ network_prov_security1_params_t* secParams = g_bleProv.pop;
+ err = network_prov_mgr_start_provisioning(
+ NETWORK_PROV_SECURITY_1,
+ static_cast(secParams),
+ g_bleProv.serviceName,
+ nullptr);
+ if (err != ESP_OK) {
+ setStatus("BLE start failed: %s", esp_err_to_name(err));
+ ESP_LOGW(TAG, "network_prov_mgr_start_provisioning failed: %s", esp_err_to_name(err));
+ network_prov_mgr_deinit();
+ g_bleProv.initialized = false;
+ return false;
+ }
+
+ g_bleProv.started = true;
+ setStatus("BLE: %s PoP %s", g_bleProv.serviceName, g_bleProv.pop);
+ return true;
+}
+
+void bleProvisioningStop() {
+ if (!g_bleProv.initialized) return;
+ if (g_bleProv.started) {
+ network_prov_mgr_stop_provisioning();
+ } else {
+ network_prov_mgr_deinit();
+ g_bleProv.initialized = false;
+ }
+ g_bleProv.started = false;
+}
+
+#else
+
+bool bleProvisioningInit(const ImprovDeviceInfo& /*info*/,
+ char* /*ssidOut*/, size_t /*ssidOutLen*/,
+ char* /*passwordOut*/, size_t /*passwordOutLen*/,
+ std::atomic* /*ready*/,
+ char* statusBuf, size_t statusBufLen) {
+ if (statusBuf && statusBufLen > 0) {
+ std::snprintf(statusBuf, statusBufLen, "BLE provisioning unavailable");
+ }
+ return false;
+}
+
+void bleProvisioningStop() {}
+
+#endif
+
+} // namespace mm::platform
diff --git a/src/platform/platform.h b/src/platform/platform.h
index e9ab013c..f4cdcc3d 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -381,6 +381,18 @@ bool improvProvisioningInit(const ImprovDeviceInfo& info,
char* opOut = nullptr, size_t opOutLen = 0,
std::atomic* opReady = nullptr);
+// BLE WiFi provisioning through Espressif's standard network_provisioning
+// phone-app protocol. Same publication model as Improv: accepted credentials
+// are copied into caller-owned buffers and `ready` is release-stored by the
+// platform event handler; the module polls from the scheduler thread and hands
+// them to NetworkModule.
+bool bleProvisioningInit(const ImprovDeviceInfo& info,
+ char* ssidOut, size_t ssidOutLen,
+ char* passwordOut, size_t passwordOutLen,
+ std::atomic* ready,
+ char* statusBuf, size_t statusBufLen);
+void bleProvisioningStop();
+
class UdpSocket {
public:
UdpSocket() = default;
From df64d9c492737508c86de0b9ff9b4ec4e57c8ae0 Mon Sep 17 00:00:00 2001
From: Funkelfetisch <40317630+Funkelfetisch@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:54:40 +0300
Subject: [PATCH 2/4] Improve Wi-Fi credential apply and reconnect handling
---
src/core/NetworkModule.h | 205 +++++++++++++++++-----
src/platform/desktop/platform_desktop.cpp | 1 +
src/platform/esp32/platform_esp32.cpp | 11 +-
src/platform/platform.h | 1 +
test/unit/core/unit_NetworkModule.cpp | 66 ++++++-
5 files changed, 232 insertions(+), 52 deletions(-)
diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h
index 454e5c65..573d120a 100644
--- a/src/core/NetworkModule.h
+++ b/src/core/NetworkModule.h
@@ -140,49 +140,13 @@ class NetworkModule : public MoonModule {
ssid_[sizeof(ssid_) - 1] = 0;
std::strncpy(password_, password ? password : "", sizeof(password_) - 1);
password_[sizeof(password_) - 1] = 0;
+ wifiCredentialApplyPending_ = false;
markDirty();
FilesystemModule::noteDirty(); // start the debounce so the change actually flushes
// (markDirty alone only sets the bit; the save scheduler
// needs noteDirty to arm — Improv-pushed creds would
// otherwise persist only if some other control changed)
- if constexpr (platform::hasWiFi) {
- // Tear down any prior WiFi state (AP-fallback, mid-flight STA
- // attempt, or stale init from a previous reconfigure) so the
- // platform's event-handler registration runs fresh.
- if (state_ == State::AP) {
- platform::wifiApStop();
- noteRadioStopped();
- apShutdownPending_ = false;
- }
- if (state_ == State::WaitingSta || state_ == State::ConnectedSta) {
- platform::wifiStaStop();
- noteRadioStopped();
- }
- if (platform::wifiStaInit(ssid_, password_)) {
- state_ = State::WaitingSta;
- stateChangeTime_ = platform::millis();
- // Apply the TX-power cap NOW, before the radio's first
- // probe / auth / assoc burst — that's the window the
- // weak-power brown-out cap exists to protect. Waiting for the
- // next tick1s() tick to syncTxPower would leave up to
- // 1 s of full-power TX during association, the exact
- // failure mode the cap defends against. syncTxPower
- // itself is cheap and idempotent.
- syncTxPower();
- std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi STA: %s", ssid_);
- setStatus(statusBuf_, Severity::Status);
- // Re-evaluate control visibility — rssi was visible while
- // state_ was ConnectedSta (any prior call to wifiStaConnected)
- // and would otherwise stay rendered with a now-stale reading
- // until the cascade either reconnects (onConnected rebuilds)
- // or falls back to AP (startAP rebuilds). Match those paths.
- rebuildControls();
- } else {
- // STA init failed (OOM, GPIO conflict). Try to recover via
- // AP so the user can re-enter credentials manually.
- startAP();
- }
- }
+ startStaFromStoredCredentials();
}
/// Networking is infrastructure — keep the cascade ticking even when the user
@@ -258,6 +222,7 @@ class NetworkModule : public MoonModule {
if constexpr (platform::hasWiFi) {
controls_.addText("ssid", ssid_, sizeof(ssid_));
controls_.addPassword("password", password_, sizeof(password_));
+ controls_.addButton("connectWifi");
// RSSI is meaningful only while associated as a STA. Hide on
// Ethernet / AP / Idle to avoid showing a stale 0 dBm reading.
controls_.addReadOnlyInt("rssi", rssi_, "dBm");
@@ -281,6 +246,9 @@ class NetworkModule : public MoonModule {
// TX-power cap is meaningless on Ethernet / Idle where the radio is off.
controls_.addInt16("txPowerSetting", txPowerSetting_, 0, 21);
controls_.setHidden(controls_.count() - 1, !radioOn);
+ updateLastDrop();
+ controls_.addReadOnly("lastDrop", lastDropBuf_, sizeof(lastDropBuf_));
+ controls_.setHidden(controls_.count() - 1, lastDropBuf_[0] == 0);
}
controls_.addBool("mDNS", mdnsEnabled_);
@@ -356,8 +324,43 @@ class NetworkModule : public MoonModule {
// Chain to base is at the top of this method — see comment there.
}
+ void onControlChanged(const char* controlName) override {
+ if constexpr (platform::hasWiFi) {
+ if (!controlName) return;
+ const bool ssidChanged = std::strcmp(controlName, "ssid") == 0;
+ const bool passwordChanged = std::strcmp(controlName, "password") == 0;
+ const bool connectRequested = std::strcmp(controlName, "connectWifi") == 0;
+ if (!ssidChanged && !passwordChanged && !connectRequested) return;
+
+ if (ssid_[0] == 0) {
+ wifiCredentialApplyPending_ = false;
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi SSID empty; AP active");
+ setStatus(statusBuf_, Severity::Status);
+ return;
+ }
+
+ if (connectRequested) {
+ wifiCredentialApplyPending_ = false;
+ startStaFromStoredCredentials();
+ return;
+ }
+
+ if (ssidChanged && password_[0] == 0) {
+ wifiCredentialApplyPending_ = false;
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi SSID saved; press connect");
+ setStatus(statusBuf_, Severity::Status);
+ return;
+ }
+
+ wifiCredentialApplyPending_ = true;
+ wifiCredentialChangedAt_ = platform::millis();
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi credentials saved; connecting soon");
+ setStatus(statusBuf_, Severity::Status);
+ }
+ }
void tick1s() override {
uint32_t now = platform::millis();
+ maybeApplyPendingWifiCredentials(now);
uint32_t elapsed = now - stateChangeTime_;
switch (state_) {
@@ -389,10 +392,10 @@ class NetworkModule : public MoonModule {
if (platform::wifiStaConnected()) {
onConnected("WiFi STA");
} else if (elapsed > 10000) {
- // WiFi STA didn't connect in 10s, start AP
platform::wifiStaStop();
noteRadioStopped();
- startAP();
+ if (staReconnectMode_) retryStaReconnect(now);
+ else startAP();
}
}
break;
@@ -433,11 +436,12 @@ class NetworkModule : public MoonModule {
platform::mdnsStop();
onConnected("Ethernet");
} else if (!platform::wifiStaConnected()) {
- std::printf("NetworkModule: WiFi STA dropped, starting AP\n");
+ std::printf("NetworkModule: WiFi STA dropped, reconnecting\n");
platform::mdnsStop();
+ mdnsRunning_ = false;
platform::wifiStaStop();
noteRadioStopped();
- startAP();
+ startStaReconnect(now, true);
} else {
updateStatusIP();
}
@@ -526,6 +530,12 @@ class NetworkModule : public MoonModule {
uint32_t stateChangeTime_ = 0;
bool apShutdownPending_ = false;
bool mdnsRunning_ = false;
+ bool wifiCredentialApplyPending_ = false;
+ bool staReconnectMode_ = false;
+ uint8_t staReconnectAttempts_ = 0;
+ uint32_t wifiCredentialChangedAt_ = 0;
+ static constexpr uint32_t kWifiCredentialApplyDelayMs = 3000;
+ static constexpr uint8_t kStaReconnectAttemptsBeforeAp = 12;
// The device name last registered with mDNS, so syncMdns() can detect a live
// rename (deviceName changed in SystemModule) and re-advertise — without it,
// the .local name would keep announcing the old name until a reconnect. 24 =
@@ -562,6 +572,7 @@ class NetworkModule : public MoonModule {
char modeStr_[20] = {}; // longest label "Ethernet (waiting)" = 19+NUL
int8_t rssi_ = 0;
int8_t txPower_ = 0;
+ char lastDropBuf_[12] = {};
// User-settable TX-power cap in whole dBm (0..21). Default 0 = "no
// override". Persisted via the control binding. The platform setter
@@ -699,6 +710,8 @@ class NetworkModule : public MoonModule {
static constexpr const char* ethTypeOptions_[] = {"None", "LAN8720", "IP101", "W5500", "YT8531"};
void startAP() {
+ staReconnectMode_ = false;
+ staReconnectAttempts_ = 0;
// Same identity as the DHCP hostname and the mDNS .local name — all three read
// SystemModule's deviceName, so a device shows ONE name everywhere. (Previously
// had a separate "MM-AP" fallback, which could diverge when the name was empty.)
@@ -727,6 +740,8 @@ class NetworkModule : public MoonModule {
} else {
state_ = State::ConnectedSta;
}
+ staReconnectMode_ = false;
+ staReconnectAttempts_ = 0;
stateChangeTime_ = platform::millis();
// Shut down lower-priority WiFi connections (no-op in the Ethernet-only build).
@@ -775,6 +790,9 @@ class NetworkModule : public MoonModule {
if (state_ == State::ConnectedSta) platform::wifiStaGetIPv4(out);
}
}
+ // Test-only accessors for the web-credential delayed-apply path.
+ bool wifiCredentialApplyPendingForTest() const { return wifiCredentialApplyPending_; }
+ static constexpr uint32_t wifiCredentialApplyDelayMsForTest() { return kWifiCredentialApplyDelayMs; }
private:
/// The device's network name is owned solely by SystemModule; NetworkModule only
@@ -799,6 +817,106 @@ class NetworkModule : public MoonModule {
setStatus(statusBuf_, Severity::Status);
}
+ void maybeApplyPendingWifiCredentials(uint32_t now) {
+ if constexpr (platform::hasWiFi) {
+ if (!wifiCredentialApplyPending_) return;
+ if (now - wifiCredentialChangedAt_ < kWifiCredentialApplyDelayMs) return;
+ wifiCredentialApplyPending_ = false;
+
+ if (ssid_[0] == 0) {
+ if (state_ != State::AP) startAP();
+ return;
+ }
+ startStaFromStoredCredentials();
+ }
+ }
+
+ void startStaFromStoredCredentials() {
+ if constexpr (platform::hasWiFi) {
+ if (ssid_[0] == 0) {
+ if (state_ != State::AP) startAP();
+ return;
+ }
+
+ if (state_ == State::ConnectedEth || state_ == State::WaitingEth) {
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi credentials saved");
+ setStatus(statusBuf_, Severity::Status);
+ return;
+ }
+
+ staReconnectMode_ = false;
+ staReconnectAttempts_ = 0;
+
+ if (state_ == State::AP) {
+ platform::wifiApStop();
+ noteRadioStopped();
+ apShutdownPending_ = false;
+ }
+ if (state_ == State::WaitingSta || state_ == State::ConnectedSta) {
+ platform::mdnsStop();
+ mdnsRunning_ = false;
+ platform::wifiStaStop();
+ noteRadioStopped();
+ }
+
+ if (platform::wifiStaInit(ssid_, password_)) {
+ state_ = State::WaitingSta;
+ stateChangeTime_ = platform::millis();
+ syncTxPower();
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi STA: %s", ssid_);
+ setStatus(statusBuf_, Severity::Status);
+ rebuildControls();
+ if (scheduler_) scheduler_->buildState();
+ } else {
+ startAP();
+ }
+ }
+ }
+
+ void updateLastDrop() {
+ if constexpr (platform::hasWiFi) {
+ const uint8_t reason = platform::wifiStaDisconnectReason();
+ if (reason == 0) {
+ lastDropBuf_[0] = 0;
+ } else {
+ std::snprintf(lastDropBuf_, sizeof(lastDropBuf_), "%u", static_cast(reason));
+ }
+ }
+ }
+
+ void startStaReconnect(uint32_t now, bool resetAttempts) {
+ if constexpr (platform::hasWiFi) {
+ if (resetAttempts) staReconnectAttempts_ = 0;
+ staReconnectMode_ = true;
+ retryStaReconnect(now);
+ }
+ }
+
+ void retryStaReconnect(uint32_t now) {
+ if constexpr (platform::hasWiFi) {
+ if (ssid_[0] == 0 || staReconnectAttempts_ >= kStaReconnectAttemptsBeforeAp) {
+ std::printf("NetworkModule: WiFi reconnect exhausted, starting AP\n");
+ startAP();
+ return;
+ }
+
+ staReconnectAttempts_++;
+ if (platform::wifiStaInit(ssid_, password_)) {
+ state_ = State::WaitingSta;
+ stateChangeTime_ = now;
+ syncTxPower();
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi reconnect %u/%u",
+ static_cast(staReconnectAttempts_),
+ static_cast(kStaReconnectAttemptsBeforeAp));
+ setStatus(statusBuf_, Severity::Warning);
+ rebuildControls();
+ if (scheduler_) scheduler_->buildState();
+ } else {
+ std::printf("NetworkModule: WiFi reconnect init failed, starting AP\n");
+ startAP();
+ }
+ }
+ }
// Apply txPowerSetting_ to the radio whenever it changes (UI write,
// board-injected value, or first time it lands after STA/AP comes up).
// Mirrors syncMdns()'s shape: cheap idempotent check, called from
@@ -900,11 +1018,12 @@ class NetworkModule : public MoonModule {
// avoids leaving a stale 5-minute-old reading visible if the
// user toggles the hidden flag off via DevTools.
rssi_ = (state_ == State::ConnectedSta)
- ? static_cast(platform::wifiStaRssi()) : 0;
+ ? static_cast(platform::wifiStaRssi()) : 0;
const bool radioOn = (state_ == State::ConnectedSta
|| state_ == State::WaitingSta
|| state_ == State::AP);
txPower_ = radioOn ? static_cast(platform::wifiTxPower()) : 0;
+ updateLastDrop();
}
}
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 661f94d0..61a3700b 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -571,6 +571,7 @@ bool wifiStaConnected() { return false; }
void wifiStaGetIPv4(uint8_t out[4]) { out[0] = out[1] = out[2] = out[3] = 0; }
void setHostname(const char* /*name*/) {} // no DHCP client on desktop
void wifiStaStop() {}
+uint8_t wifiStaDisconnectReason() { return 0; }
int wifiStaRssi() { return 0; }
void wifiStaBssid(uint8_t out[6]) { std::memset(out, 0, 6); }
int wifiStaChannel() { return 0; }
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 2010d46c..7cf7ef77 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -415,6 +415,7 @@ static bool wifiApActive_ = false;
static esp_netif_t* staNetif_ = nullptr;
static esp_netif_t* apNetif_ = nullptr;
static bool wifiInitDone_ = false;
+static uint8_t wifiStaDisconnectReason_ = 0;
#endif
static void ensureNetifInit() {
@@ -763,7 +764,10 @@ static void wifiEventHandler(void* /*arg*/, esp_event_base_t base,
int32_t id, void* data) {
if (base == WIFI_EVENT) {
if (id == WIFI_EVENT_STA_DISCONNECTED) {
- ESP_LOGI(NET_TAG, "WiFi STA disconnected");
+ auto* event = static_cast(data);
+ wifiStaDisconnectReason_ = event ? static_cast(event->reason) : 0;
+ ESP_LOGI(NET_TAG, "WiFi STA disconnected, reason=%u",
+ static_cast(wifiStaDisconnectReason_));
wifiStaConnected_ = false;
} else if (id == WIFI_EVENT_AP_STACONNECTED) {
ESP_LOGI(NET_TAG, "WiFi AP client connected");
@@ -922,6 +926,10 @@ void wifiStaStop() {
ESP_LOGI(NET_TAG, "WiFi STA stopped + deinit");
}
+uint8_t wifiStaDisconnectReason() {
+ return wifiStaDisconnectReason_;
+}
+
int wifiStaRssi() {
if (!wifiStaConnected_) return 0;
wifi_ap_record_t info{};
@@ -1050,6 +1058,7 @@ bool wifiStaInit(const char* /*ssid*/, const char* /*password*/) { return false;
bool wifiStaConnected() { return false; }
void wifiStaGetIPv4(uint8_t out[4]) { out[0] = out[1] = out[2] = out[3] = 0; }
void wifiStaStop() {}
+uint8_t wifiStaDisconnectReason() { return 0; }
int wifiStaRssi() { return 0; }
void wifiStaBssid(uint8_t out[6]) { std::memset(out, 0, 6); }
int wifiStaChannel() { return 0; }
diff --git a/src/platform/platform.h b/src/platform/platform.h
index f4cdcc3d..dc87f8af 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -225,6 +225,7 @@ bool wifiStaInit(const char* ssid, const char* password);
bool wifiStaConnected();
void wifiStaGetIPv4(uint8_t out[4]); // see ethGetIPv4 — same octet contract
void wifiStaStop();
+uint8_t wifiStaDisconnectReason(); // last ESP-IDF STA disconnect reason, 0 = none/unknown
// STA-side RSSI in dBm (negative, e.g. -58). Returns 0 when the STA isn't
// associated or the call fails — NetworkModule only surfaces this control
diff --git a/test/unit/core/unit_NetworkModule.cpp b/test/unit/core/unit_NetworkModule.cpp
index 1e428d5c..3afd8178 100644
--- a/test/unit/core/unit_NetworkModule.cpp
+++ b/test/unit/core/unit_NetworkModule.cpp
@@ -1,17 +1,15 @@
// @module NetworkModule
-// Unit tests for NetworkModule::setWifiCredentials — the bridge the Improv
-// listener (and any future credential pusher) uses to hand SSID + password
-// off to the network state machine without touching control bindings.
-//
-// Scope is narrow on purpose: this verifies the buffer copy + dirty flag,
-// not the WiFi state transitions (which need the platform layer, a real
-// radio, and the tick1s() tick). The desktop platform's wifiStaInit stub
-// returns false safely, so the call completes without raising side effects.
+// Unit tests for NetworkModule WiFi credentials, network-state controls,
+// and IP validators. The desktop WiFi backend is a safe stub: it lets tests
+// verify buffer copies, delayed web-control apply, and fallback behaviour
+// without requiring a real radio.
#include "doctest.h"
#include "platform_config.h" // pulls in platform::hasWiFi before NetworkModule.h
#include "core/NetworkModule.h"
+#include "core/Scheduler.h"
+#include "platform/platform.h"
#include "conditional_controls.h" // shared conditional-control invariant helpers
#include
@@ -65,6 +63,58 @@ TEST_CASE("NetworkModule::setWifiCredentials accepts long SSID without crash") {
CHECK(net.dirty());
}
+namespace {
+struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } };
+}
+
+// Web-written credentials drive the same STA apply path as Improv, after both fields settle.
+TEST_CASE("NetworkModule web credential controls schedule WiFi STA apply") {
+ ClockGuard clockGuard;
+ mm::platform::setTestNowMs(1000);
+
+ mm::Scheduler scheduler;
+ auto* net = new mm::NetworkModule();
+ net->setName("Network");
+ net->setScheduler(&scheduler);
+ scheduler.addModule(net);
+ scheduler.setup();
+
+ CHECK(scheduler.setControl("Network", "ssid", "{\"value\":\"homeAP\"}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
+ CHECK(std::strcmp(net->status(), "WiFi SSID saved; press connect") == 0);
+
+ CHECK(scheduler.setControl("Network", "password", "{\"value\":\"secret123\"}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK(net->wifiCredentialApplyPendingForTest());
+ CHECK(std::strcmp(net->status(), "WiFi credentials saved; connecting soon") == 0);
+
+ mm::platform::setTestNowMs(1000 + mm::NetworkModule::wifiCredentialApplyDelayMsForTest() + 1);
+ net->tick1s();
+
+ CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
+ CHECK(std::strcmp(net->status(), "No network") == 0);
+ scheduler.release();
+}
+
+// The explicit connect button lets an open network apply with an empty password.
+TEST_CASE("NetworkModule connectWifi applies open-network credentials") {
+ ClockGuard clockGuard;
+ mm::platform::setTestNowMs(1000);
+
+ mm::Scheduler scheduler;
+ auto* net = new mm::NetworkModule();
+ net->setName("Network");
+ net->setScheduler(&scheduler);
+ scheduler.addModule(net);
+ scheduler.setup();
+
+ CHECK(scheduler.setControl("Network", "ssid", "{\"value\":\"openAP\"}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
+ CHECK(scheduler.setControl("Network", "connectWifi", "{\"value\":1}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
+ CHECK(std::strcmp(net->status(), "No network") == 0);
+ scheduler.release();
+}
+
// After setup(), NetworkModule exposes a `mode` read-only control whose value
// reflects the current state-machine state. On the desktop platform every
// network init stub returns false, so the cascade lands on Idle.
From 500ae168f525722cbf513d25549adfb1adbb59ca Mon Sep 17 00:00:00 2001
From: Funkelfetisch <40317630+Funkelfetisch@users.noreply.github.com>
Date: Sat, 11 Jul 2026 21:45:36 +0200
Subject: [PATCH 3/4] Address BLE provisioning review feedback
Drops the public MAC-derived proof-of-possession path, uses security 0 instead of pretending to have a secret, makes BLE provisioning state atomic, and restores AP mode when an empty SSID is applied through web controls.
Checks: check_specs.py; git diff --check; build_esp32.py --firmware esp32s3-n16r8 (projectMM.bin 0x1cd630, total image 1889720 bytes).
Notes: This PR is BLE Wi-Fi provisioning only; it does not add BLE firmware OTA.
---
esp32/sdkconfig.defaults.ble-provisioning | 5 +--
src/core/NetworkModule.h | 3 +-
src/platform/desktop/platform_desktop.cpp | 6 ++-
src/platform/esp32/platform_esp32.cpp | 1 +
.../esp32/platform_esp32_ble_provisioning.cpp | 43 ++++++++-----------
src/platform/platform.h | 1 +
test/unit/core/unit_NetworkModule.cpp | 40 ++++++++++++++++-
7 files changed, 68 insertions(+), 31 deletions(-)
diff --git a/esp32/sdkconfig.defaults.ble-provisioning b/esp32/sdkconfig.defaults.ble-provisioning
index f915228a..85e37643 100644
--- a/esp32/sdkconfig.defaults.ble-provisioning
+++ b/esp32/sdkconfig.defaults.ble-provisioning
@@ -6,6 +6,5 @@ CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI=y
-# Use protocomm Security 1 (X25519 + PoP + AES-CTR), with a per-device PoP
-# derived from the MAC suffix at runtime.
-CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_1=y
+# Provisioning runs without PoP until boards have a real out-of-band secret.
+CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_0=y
diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h
index 573d120a..cbe59011 100644
--- a/src/core/NetworkModule.h
+++ b/src/core/NetworkModule.h
@@ -334,8 +334,7 @@ class NetworkModule : public MoonModule {
if (ssid_[0] == 0) {
wifiCredentialApplyPending_ = false;
- std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi SSID empty; AP active");
- setStatus(statusBuf_, Severity::Status);
+ if (state_ != State::AP) startAP();
return;
}
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 61a3700b..83f95da7 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -98,8 +98,10 @@ static auto startTime = std::chrono::steady_clock::now();
// Test-only override for millis(); 0 means "use the real clock". std::atomic so
// a test can set it from one thread while a tested module reads from another.
static std::atomic testNowMs{0};
+static std::atomic testWifiApInitResult{false};
void setTestNowMs(uint32_t ms) { testNowMs.store(ms, std::memory_order_relaxed); }
+void setTestWifiApInitResult(bool ok) { testWifiApInitResult.store(ok, std::memory_order_relaxed); }
uint32_t millis() {
uint32_t override_ = testNowMs.load(std::memory_order_relaxed);
@@ -576,7 +578,9 @@ int wifiStaRssi() { return 0; }
void wifiStaBssid(uint8_t out[6]) { std::memset(out, 0, 6); }
int wifiStaChannel() { return 0; }
-bool wifiApInit(const char* /*apName*/, const char* /*ip*/) { return false; }
+bool wifiApInit(const char* /*apName*/, const char* /*ip*/) {
+ return testWifiApInitResult.load(std::memory_order_relaxed);
+}
bool wifiApConnected() { return false; }
void wifiApStop() {}
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 7cf7ef77..8cee41a9 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -84,6 +84,7 @@ namespace mm::platform {
static std::atomic testNowMs{0};
void setTestNowMs(uint32_t ms) { testNowMs.store(ms, std::memory_order_relaxed); }
+void setTestWifiApInitResult(bool) {}
uint32_t millis() {
uint32_t override_ = testNowMs.load(std::memory_order_relaxed);
diff --git a/src/platform/esp32/platform_esp32_ble_provisioning.cpp b/src/platform/esp32/platform_esp32_ble_provisioning.cpp
index a19ec5d6..78a27ad1 100644
--- a/src/platform/esp32/platform_esp32_ble_provisioning.cpp
+++ b/src/platform/esp32/platform_esp32_ble_provisioning.cpp
@@ -22,7 +22,6 @@ constexpr const char* TAG = "mm-ble-prov";
struct BleProvisioningState {
char serviceName[32] = {};
- char pop[8] = {};
char pendingSsid[33] = {};
char pendingPassword[64] = {};
char* ssidOut = nullptr;
@@ -32,8 +31,8 @@ struct BleProvisioningState {
std::atomic* ready = nullptr;
char* statusBuf = nullptr;
size_t statusBufLen = 0;
- bool initialized = false;
- bool started = false;
+ std::atomic initialized{false};
+ std::atomic started{false};
};
BleProvisioningState g_bleProv;
@@ -70,16 +69,13 @@ void makeIdentity(const ImprovDeviceInfo& info) {
const char* name = (info.name && info.name[0]) ? info.name : "projectMM";
std::snprintf(g_bleProv.serviceName, sizeof(g_bleProv.serviceName),
"%.20s-%02X%02X%02X", name, mac[3], mac[4], mac[5]);
- std::snprintf(g_bleProv.pop, sizeof(g_bleProv.pop),
- "%02X%02X%02X", mac[3], mac[4], mac[5]);
}
void bleProvEventCb(void* /*user_data*/, network_prov_cb_event_t event, void* event_data) {
switch (event) {
case NETWORK_PROV_START:
- setStatus("BLE: %s PoP %s", g_bleProv.serviceName, g_bleProv.pop);
- ESP_LOGI(TAG, "BLE provisioning started, service=%s pop=%s",
- g_bleProv.serviceName, g_bleProv.pop);
+ setStatus("BLE: %s", g_bleProv.serviceName);
+ ESP_LOGI(TAG, "BLE provisioning started, service=%s", g_bleProv.serviceName);
break;
case NETWORK_PROV_WIFI_CRED_RECV: {
auto* cfg = static_cast(event_data);
@@ -111,12 +107,12 @@ void bleProvEventCb(void* /*user_data*/, network_prov_cb_event_t event, void* ev
setStatus("BLE provisioning stopped");
}
network_prov_mgr_deinit();
- g_bleProv.started = false;
- g_bleProv.initialized = false;
+ g_bleProv.started.store(false, std::memory_order_release);
+ g_bleProv.initialized.store(false, std::memory_order_release);
break;
case NETWORK_PROV_DEINIT:
- g_bleProv.started = false;
- g_bleProv.initialized = false;
+ g_bleProv.started.store(false, std::memory_order_release);
+ g_bleProv.initialized.store(false, std::memory_order_release);
break;
default:
break;
@@ -130,7 +126,7 @@ bool bleProvisioningInit(const ImprovDeviceInfo& info,
char* passwordOut, size_t passwordOutLen,
std::atomic* ready,
char* statusBuf, size_t statusBufLen) {
- if (g_bleProv.started) return true;
+ if (g_bleProv.started.load(std::memory_order_acquire)) return true;
if (!ssidOut || ssidOutLen == 0 || !passwordOut || passwordOutLen == 0 || !ready) {
return false;
}
@@ -161,36 +157,35 @@ bool bleProvisioningInit(const ImprovDeviceInfo& info,
ESP_LOGW(TAG, "network_prov_mgr_init failed: %s", esp_err_to_name(err));
return false;
}
- g_bleProv.initialized = true;
+ g_bleProv.initialized.store(true, std::memory_order_release);
- network_prov_security1_params_t* secParams = g_bleProv.pop;
err = network_prov_mgr_start_provisioning(
- NETWORK_PROV_SECURITY_1,
- static_cast(secParams),
+ NETWORK_PROV_SECURITY_0,
+ nullptr,
g_bleProv.serviceName,
nullptr);
if (err != ESP_OK) {
setStatus("BLE start failed: %s", esp_err_to_name(err));
ESP_LOGW(TAG, "network_prov_mgr_start_provisioning failed: %s", esp_err_to_name(err));
network_prov_mgr_deinit();
- g_bleProv.initialized = false;
+ g_bleProv.initialized.store(false, std::memory_order_release);
return false;
}
- g_bleProv.started = true;
- setStatus("BLE: %s PoP %s", g_bleProv.serviceName, g_bleProv.pop);
+ g_bleProv.started.store(true, std::memory_order_release);
+ setStatus("BLE: %s", g_bleProv.serviceName);
return true;
}
void bleProvisioningStop() {
- if (!g_bleProv.initialized) return;
- if (g_bleProv.started) {
+ if (!g_bleProv.initialized.load(std::memory_order_acquire)) return;
+ if (g_bleProv.started.load(std::memory_order_acquire)) {
network_prov_mgr_stop_provisioning();
} else {
network_prov_mgr_deinit();
- g_bleProv.initialized = false;
+ g_bleProv.initialized.store(false, std::memory_order_release);
}
- g_bleProv.started = false;
+ g_bleProv.started.store(false, std::memory_order_release);
}
#else
diff --git a/src/platform/platform.h b/src/platform/platform.h
index dc87f8af..e9cd1b0c 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -17,6 +17,7 @@ uint32_t micros();
// in release so cases stay independent. ESP32 honours the override too so a
// scenario-tests run on real hardware can still freeze time if needed.
void setTestNowMs(uint32_t ms);
+void setTestWifiApInitResult(bool ok);
void* alloc(size_t bytes);
void free(void* ptr);
diff --git a/test/unit/core/unit_NetworkModule.cpp b/test/unit/core/unit_NetworkModule.cpp
index 3afd8178..77d68eb0 100644
--- a/test/unit/core/unit_NetworkModule.cpp
+++ b/test/unit/core/unit_NetworkModule.cpp
@@ -64,7 +64,21 @@ TEST_CASE("NetworkModule::setWifiCredentials accepts long SSID without crash") {
}
namespace {
-struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } };
+struct ClockGuard {
+ ~ClockGuard() {
+ mm::platform::setTestNowMs(0);
+ mm::platform::setTestWifiApInitResult(false);
+ }
+};
+
+const char* modeControlValue(const mm::NetworkModule& net) {
+ for (uint8_t i = 0; i < net.controls().count(); i++) {
+ if (std::strcmp(net.controls()[i].name, "mode") == 0) {
+ return static_cast(net.controls()[i].ptr);
+ }
+ }
+ return nullptr;
+}
}
// Web-written credentials drive the same STA apply path as Improv, after both fields settle.
@@ -115,6 +129,30 @@ TEST_CASE("NetworkModule connectWifi applies open-network credentials") {
scheduler.release();
}
+// Clearing SSID through the generic control path starts AP mode rather than only
+// changing status text.
+TEST_CASE("NetworkModule clearing SSID through web controls starts AP") {
+ ClockGuard clockGuard;
+ mm::platform::setTestNowMs(1000);
+ mm::platform::setTestWifiApInitResult(true);
+
+ mm::Scheduler scheduler;
+ auto* net = new mm::NetworkModule();
+ net->setName("Network");
+ net->setScheduler(&scheduler);
+ scheduler.addModule(net);
+ scheduler.setup();
+
+ CHECK(scheduler.setControl("Network", "ssid", "{\"value\":\"homeAP\"}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK(scheduler.setControl("Network", "connectWifi", "{\"value\":1}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK(scheduler.setControl("Network", "ssid", "{\"value\":\"\"}") == mm::Scheduler::SetControlResult::Ok);
+
+ CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
+ REQUIRE(modeControlValue(*net) != nullptr);
+ CHECK(std::strcmp(modeControlValue(*net), "WiFi AP") == 0);
+ scheduler.release();
+}
+
// After setup(), NetworkModule exposes a `mode` read-only control whose value
// reflects the current state-machine state. On the desktop platform every
// network init stub returns false, so the cascade lands on Idle.
From feb712ed0a40a3d1a5969b0dfc0a81cfef43d348 Mon Sep 17 00:00:00 2001
From: Funkelfetisch <40317630+Funkelfetisch@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:13:32 +0200
Subject: [PATCH 4/4] Gate BLE provisioning to AP fallback and add tests
Keep unauthenticated BLE onboarding available only while Network is in AP fallback, inject platform calls at runtime, and add lifecycle and credential tests.
KPI: 16384lights | tick:673/159/152/705/1656/637/9/526/197/119/12/734/1622/104us(FPS:1485/6289/6578/1418/603/1569/111111/1901/5076/8403/83333/1362/616/9615) | src:192(37213) | test:128(19441)
- Core: inject a platform-neutral BLE runtime and stop advertising once the network is configured.
- Tests: add BLE startup, retry, credential publication, password clearing, and release coverage; refresh required desktop scenario observations.
- Validation: 24 focused C++ tests, 20 scenarios, 55 Python tests, specs, platform boundary, firmware catalog, and BLE-enabled ESP32-S3 build pass.
- Gate note: full ctest is blocked by the existing x86 Zig unused-function warning in test/unit/core/unit_moonlive_ir.cpp; the focused BLE and Network suite passes.
---
...0712 - BLE provisioning upstream rebase.md | 23 ++++
docs/moonmodules/core/system.md | 6 +-
src/core/BleProvisioningModule.h | 94 ++++++++-----
src/core/NetworkModule.h | 6 +-
src/main.cpp | 24 ++++
src/platform/esp32/platform_esp32.cpp | 2 +
test/CMakeLists.txt | 1 +
.../scenario_MoonModule_control_change.json | 4 +-
.../light/scenario_Audio_mutation.json | 24 ++--
.../light/scenario_Driver_mutation.json | 20 +--
.../light/scenario_GridLayout_resize.json | 4 +-
.../light/scenario_Layer_memory_1to1.json | 4 +-
.../light/scenario_Layouts_mutation.json | 12 +-
.../scenario_MultiplyModifier_memory_lut.json | 4 +-
.../scenario_MultiplyModifier_pipeline.json | 4 +-
.../light/scenario_modifier_chain.json | 16 +--
.../light/scenario_modifier_swap.json | 12 +-
test/scenarios/light/scenario_perf_full.json | 76 +++++------
test/scenarios/light/scenario_perf_light.json | 24 ++--
test/unit/core/unit_BleProvisioningModule.cpp | 125 ++++++++++++++++++
20 files changed, 345 insertions(+), 140 deletions(-)
create mode 100644 docs/history/plans/Plan-20260712 - BLE provisioning upstream rebase.md
create mode 100644 test/unit/core/unit_BleProvisioningModule.cpp
diff --git a/docs/history/plans/Plan-20260712 - BLE provisioning upstream rebase.md b/docs/history/plans/Plan-20260712 - BLE provisioning upstream rebase.md
new file mode 100644
index 00000000..02c505e1
--- /dev/null
+++ b/docs/history/plans/Plan-20260712 - BLE provisioning upstream rebase.md
@@ -0,0 +1,23 @@
+# BLE provisioning upstream rebase
+
+## Context
+
+The BLE provisioning branch predates the current lifecycle names and System/Services split. Its core module also includes the platform API directly, which crosses the repository's platform boundary. Security0 remains necessary until hardware carries a real out-of-band secret, so the BLE service must be restricted to the device's AP fallback onboarding state.
+
+## Alternatives
+
+- Keep direct platform calls: removes adapter code, but violates the platform boundary and leaves core behavior hard to test.
+- Inject a plain function table: adds one small data struct, follows the reviewed auto-update pattern, and enables desktop unit tests.
+- Fold BLE into Improv provisioning: removes one module, but combines separate Espressif and Improv protocols and expands the change well beyond the review feedback.
+
+## Choice
+
+Inject a plain function table. Keep BLE as fixed Network infrastructure, advertise only while Network is in AP fallback, and preserve Network as the sole owner of credential persistence and reconnect state.
+
+## Work
+
+1. Port the module to `defineControls`, `tick1s`, and `release`.
+2. Inject platform start/stop/time/chip functions from `main.cpp`.
+3. Add desktop unit coverage for AP-gated startup, retry, credential publication, password clearing, and release.
+4. Run spec, platform-boundary, desktop, unit, scenario, and ESP32 checks relevant to the branch.
+5. Test provisioning over Windows BLE on hardware, then update the PR review threads with verified results.
diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md
index ab6fc228..454b1009 100644
--- a/docs/moonmodules/core/system.md
+++ b/docs/moonmodules/core/system.md
@@ -43,7 +43,7 @@ Detail: [technical](moxygen/NetworkModule.md)
### Improv provisioning
-Serial/BLE Improv Wi-Fi provisioning — the web installer hands credentials to a fresh device over this protocol during the flash-and-connect flow.
+Serial Improv Wi-Fi provisioning — the web installer hands credentials to a fresh device over this protocol during the flash-and-connect flow.
@@ -55,12 +55,14 @@ Detail: [technical](moxygen/ImprovProvisioningModule.md)
### BLE provisioning
-Espressif BLE Wi-Fi provisioning for phone-app onboarding. The service accepts credentials locally and hands them to Network, which owns connection, persistence, and AP fallback.
+Espressif BLE Wi-Fi provisioning for phone-app onboarding. It advertises only while Network is in AP fallback, accepts credentials locally, and hands them to Network, which owns connection and persistence. The current Security0 transport has no proof-of-possession secret; restricting it to onboarding keeps configured devices from reopening unauthenticated provisioning after every reboot.
- `ble_status` — read-only provisioning state and BLE service name.
Detail: [technical](moxygen/BleProvisioningModule.md)
+[Tests](../../tests/unit-tests.md#bleprovisioningmodule)
+
### Devices
diff --git a/src/core/BleProvisioningModule.h b/src/core/BleProvisioningModule.h
index 9aa922b2..5222ee2c 100644
--- a/src/core/BleProvisioningModule.h
+++ b/src/core/BleProvisioningModule.h
@@ -4,14 +4,26 @@
#include "core/NetworkModule.h"
#include "core/SystemModule.h"
#include "core/build_info.h"
-#include "platform/platform.h"
#include
+#include
+#include
#include
#include
namespace mm {
+struct BleProvisioningRuntime {
+ uint32_t (*millis)() = nullptr;
+ const char* (*chipModel)() = nullptr;
+ bool (*start)(const char* deviceName, const char* chipModel, const char* version,
+ char* ssidOut, size_t ssidOutLen,
+ char* passwordOut, size_t passwordOutLen,
+ std::atomic* ready,
+ char* statusBuf, size_t statusBufLen) = nullptr;
+ void (*stop)() = nullptr;
+};
+
/// Standard Espressif BLE WiFi provisioning. The BLE transport handles the
/// local credential exchange; this module publishes accepted credentials back
/// into NetworkModule so persistence, STA retry, and AP fallback stay in one
@@ -20,69 +32,83 @@ class BleProvisioningModule : public MoonModule {
public:
void setSystemModule(SystemModule* s) { systemModule_ = s; }
void setNetworkModule(NetworkModule* n) { networkModule_ = n; }
+ void setRuntime(const BleProvisioningRuntime* runtime) { runtime_ = runtime; }
bool respectsEnabled() const override { return false; }
bool userEditable() const override { return false; }
void setup() override {
- tryStart();
+ if (!runtimeReady()) {
+ copyStatus("BLE provisioning unavailable");
+ } else if (networkModule_ && networkModule_->provisioningMode()) {
+ tryStart();
+ } else {
+ copyStatus("BLE inactive (network configured)");
+ }
+ MoonModule::setup();
}
- void onBuildControls() override {
- setStatus(statusStr_);
+ void defineControls() override {
+ MoonModule::defineControls();
controls_.addReadOnly("ble_status", statusStr_, sizeof(statusStr_));
}
- void loop1s() override {
+ void tick1s() override {
if (pendingCredentials_.load(std::memory_order_acquire) && networkModule_) {
networkModule_->setWifiCredentials(pendingSsid_, pendingPassword_);
std::memset(pendingPassword_, 0, sizeof(pendingPassword_));
pendingCredentials_.store(false, std::memory_order_release);
}
- if constexpr (platform::hasBleProvisioning) {
- if (!started_) {
- const uint32_t now = platform::millis();
- if (now - lastStartAttemptMs_ >= kRetryMs) {
- tryStart();
- }
+ if (started_ && networkModule_ && !networkModule_->provisioningMode()) {
+ runtime_->stop();
+ started_ = false;
+ } else if (!started_ && runtimeReady() && networkModule_ && networkModule_->provisioningMode()) {
+ const uint32_t now = runtime_->millis();
+ if (now - lastStartAttemptMs_ >= kRetryMs) {
+ tryStart();
}
}
+ MoonModule::tick1s();
}
- void teardown() override {
- if constexpr (platform::hasBleProvisioning) {
- platform::bleProvisioningStop();
- }
+ void release() override {
+ if (started_ && runtime_ && runtime_->stop) runtime_->stop();
+ started_ = false;
+ pendingCredentials_.store(false, std::memory_order_release);
+ std::memset(pendingPassword_, 0, sizeof(pendingPassword_));
+ MoonModule::release();
}
private:
+ bool runtimeReady() const {
+ return runtime_ && runtime_->millis && runtime_->chipModel && runtime_->start && runtime_->stop;
+ }
+
+ void copyStatus(const char* status) {
+ std::snprintf(statusStr_, sizeof(statusStr_), "%s", status ? status : "");
+ setStatus(statusStr_);
+ }
+
void tryStart() {
- if constexpr (platform::hasBleProvisioning) {
- lastStartAttemptMs_ = platform::millis();
- const char* deviceName = systemModule_ ? systemModule_->deviceName() : "projectMM";
- platform::ImprovDeviceInfo info{
- deviceName,
- platform::chipModel(),
- kVersion,
- };
- started_ = platform::bleProvisioningInit(
- info,
- pendingSsid_, sizeof(pendingSsid_),
- pendingPassword_, sizeof(pendingPassword_),
- &pendingCredentials_,
- statusStr_, sizeof(statusStr_));
- } else {
- std::strncpy(statusStr_, "not supported on this platform", sizeof(statusStr_) - 1);
- started_ = false;
- }
+ if (!runtimeReady()) return;
+ lastStartAttemptMs_ = runtime_->millis();
+ const char* deviceName = systemModule_ ? systemModule_->deviceName() : "projectMM";
+ started_ = runtime_->start(
+ deviceName, runtime_->chipModel(), kVersion,
+ pendingSsid_, sizeof(pendingSsid_),
+ pendingPassword_, sizeof(pendingPassword_),
+ &pendingCredentials_,
+ statusStr_, sizeof(statusStr_));
+ setStatus(statusStr_);
}
static constexpr uint32_t kRetryMs = 5000;
SystemModule* systemModule_ = nullptr;
NetworkModule* networkModule_ = nullptr;
- char statusStr_[96] = "BLE provisioning starting";
+ const BleProvisioningRuntime* runtime_ = nullptr;
+ char statusStr_[96] = "BLE provisioning idle";
char pendingSsid_[33] = {};
char pendingPassword_[64] = {};
std::atomic pendingCredentials_{false};
diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h
index cbe59011..61cd7920 100644
--- a/src/core/NetworkModule.h
+++ b/src/core/NetworkModule.h
@@ -789,9 +789,11 @@ class NetworkModule : public MoonModule {
if (state_ == State::ConnectedSta) platform::wifiStaGetIPv4(out);
}
}
+ bool provisioningMode() const { return state_ == State::AP; }
// Test-only accessors for the web-credential delayed-apply path.
bool wifiCredentialApplyPendingForTest() const { return wifiCredentialApplyPending_; }
static constexpr uint32_t wifiCredentialApplyDelayMsForTest() { return kWifiCredentialApplyDelayMs; }
+ void setProvisioningModeForTest(bool active) { state_ = active ? State::AP : State::Idle; }
private:
/// The device's network name is owned solely by SystemModule; NetworkModule only
@@ -865,7 +867,7 @@ class NetworkModule : public MoonModule {
std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi STA: %s", ssid_);
setStatus(statusBuf_, Severity::Status);
rebuildControls();
- if (scheduler_) scheduler_->buildState();
+ if (scheduler_) scheduler_->prepareTree();
} else {
startAP();
}
@@ -909,7 +911,7 @@ class NetworkModule : public MoonModule {
static_cast(kStaReconnectAttemptsBeforeAp));
setStatus(statusBuf_, Severity::Warning);
rebuildControls();
- if (scheduler_) scheduler_->buildState();
+ if (scheduler_) scheduler_->prepareTree();
} else {
std::printf("NetworkModule: WiFi reconnect init failed, starting AP\n");
startAP();
diff --git a/src/main.cpp b/src/main.cpp
index 7c7f1d55..81db7346 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -114,8 +114,31 @@
#include "core/NetworkModule.h"
+#include
#include
+namespace {
+
+bool startBleProvisioning(const char* deviceName, const char* chipModel, const char* version,
+ char* ssidOut, size_t ssidOutLen,
+ char* passwordOut, size_t passwordOutLen,
+ std::atomic* ready,
+ char* statusBuf, size_t statusBufLen) {
+ const mm::platform::ImprovDeviceInfo info{deviceName, chipModel, version};
+ return mm::platform::bleProvisioningInit(
+ info, ssidOut, ssidOutLen, passwordOut, passwordOutLen,
+ ready, statusBuf, statusBufLen);
+}
+
+[[maybe_unused]] const mm::BleProvisioningRuntime kBleProvisioningRuntime{
+ mm::platform::millis,
+ mm::platform::chipModel,
+ startBleProvisioning,
+ mm::platform::bleProvisioningStop,
+};
+
+} // namespace
+
static void registerModuleTypes() {
// Second argument is the module's spec page relative to docs/moonmodules/ —
// the UI builds a help link from it. Effects/modifiers/leaf-layouts share one
@@ -374,6 +397,7 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) {
bleProvisioningModule->setName("BLE Provision");
bleProvisioningModule->setSystemModule(systemModule);
bleProvisioningModule->setNetworkModule(networkModule);
+ bleProvisioningModule->setRuntime(&kBleProvisioningRuntime);
bleProvisioningModule->markWiredByCode();
}
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 8cee41a9..996beb40 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -1119,6 +1119,7 @@ bool mdnsInit(const char* deviceName) {
// interface. Idempotent on the targets where the predef ETH already covers it:
// mdns_register_netif returns ESP_ERR_INVALID_STATE ("already registered"), which we
// treat as success, so this one path fixes the P4 without regressing S31/classic/S3.
+#ifndef MM_NO_ETH
if (ethNetif_ && ethConnected()) {
esp_err_t regErr = mdns_register_netif(ethNetif_);
if (regErr == ESP_OK || regErr == ESP_ERR_INVALID_STATE) {
@@ -1129,6 +1130,7 @@ bool mdnsInit(const char* deviceName) {
ESP_LOGW(NET_TAG, "mDNS eth netif register failed: %s", esp_err_to_name(regErr));
}
}
+#endif
// FORCE A FRESH RE-ADVERTISE: remove any existing service record, then add it back.
// A reconnect / interface switch / live rename re-runs this; just renaming the
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 477dd164..6e9261a9 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -41,6 +41,7 @@ add_executable(mm_tests
unit/core/unit_MoonModule_lifecycle.cpp
unit/core/unit_MoonModule_movechild.cpp
unit/core/unit_MoonModule_replacechild.cpp
+ unit/core/unit_BleProvisioningModule.cpp
unit/core/unit_NetworkModule.cpp
unit/core/unit_NetworkModule_ethernet.cpp
unit/core/unit_Sort.cpp
diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json
index 18ef9913..68a1dd18 100644
--- a/test/scenarios/core/scenario_MoonModule_control_change.json
+++ b/test/scenarios/core/scenario_MoonModule_control_change.json
@@ -190,7 +190,7 @@
"desktop-windows": {
"tick_us": [
227,
- 383
+ 781
],
"free_heap": [
0,
@@ -202,7 +202,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
},
"esp32p4-eth": {
diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json
index fda315a7..bf444200 100644
--- a/test/scenarios/light/scenario_Audio_mutation.json
+++ b/test/scenarios/light/scenario_Audio_mutation.json
@@ -114,7 +114,7 @@
"desktop-windows": {
"tick_us": [
36,
- 50
+ 187
],
"free_heap": [
0,
@@ -126,7 +126,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -171,7 +171,7 @@
"desktop-windows": {
"tick_us": [
36,
- 53
+ 182
],
"free_heap": [
0,
@@ -183,7 +183,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -244,7 +244,7 @@
"desktop-windows": {
"tick_us": [
36,
- 50
+ 183
],
"free_heap": [
0,
@@ -256,7 +256,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -301,7 +301,7 @@
"desktop-windows": {
"tick_us": [
40,
- 58
+ 199
],
"free_heap": [
0,
@@ -313,7 +313,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -356,7 +356,7 @@
"desktop-windows": {
"tick_us": [
40,
- 58
+ 199
],
"free_heap": [
0,
@@ -368,7 +368,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -411,7 +411,7 @@
"desktop-windows": {
"tick_us": [
35,
- 70
+ 180
],
"free_heap": [
0,
@@ -423,7 +423,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json
index af8900fe..3d6ff976 100644
--- a/test/scenarios/light/scenario_Driver_mutation.json
+++ b/test/scenarios/light/scenario_Driver_mutation.json
@@ -95,7 +95,7 @@
"desktop-windows": {
"tick_us": [
36,
- 51
+ 164
],
"free_heap": [
0,
@@ -107,7 +107,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -152,7 +152,7 @@
"desktop-windows": {
"tick_us": [
35,
- 70
+ 164
],
"free_heap": [
0,
@@ -164,7 +164,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -209,7 +209,7 @@
"desktop-windows": {
"tick_us": [
43,
- 84
+ 165
],
"free_heap": [
0,
@@ -221,7 +221,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -264,7 +264,7 @@
"desktop-windows": {
"tick_us": [
42,
- 66
+ 163
],
"free_heap": [
0,
@@ -276,7 +276,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -319,7 +319,7 @@
"desktop-windows": {
"tick_us": [
36,
- 56
+ 155
],
"free_heap": [
0,
@@ -331,7 +331,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json
index 1eec758b..c9dce6cb 100644
--- a/test/scenarios/light/scenario_GridLayout_resize.json
+++ b/test/scenarios/light/scenario_GridLayout_resize.json
@@ -341,7 +341,7 @@
"desktop-windows": {
"tick_us": [
94,
- 164
+ 358
],
"free_heap": [
0,
@@ -353,7 +353,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
},
"esp32p4-eth": {
diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json
index 81d49b33..f2106665 100644
--- a/test/scenarios/light/scenario_Layer_memory_1to1.json
+++ b/test/scenarios/light/scenario_Layer_memory_1to1.json
@@ -99,7 +99,7 @@
"desktop-windows": {
"tick_us": [
1,
- 7
+ 9
],
"free_heap": [
0,
@@ -111,7 +111,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json
index ed9ab6b4..b07c361e 100644
--- a/test/scenarios/light/scenario_Layouts_mutation.json
+++ b/test/scenarios/light/scenario_Layouts_mutation.json
@@ -97,7 +97,7 @@
"desktop-windows": {
"tick_us": [
27,
- 60
+ 150
],
"free_heap": [
0,
@@ -109,7 +109,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
},
"esp32-eth": {
@@ -176,7 +176,7 @@
"desktop-windows": {
"tick_us": [
42,
- 185
+ 336
],
"free_heap": [
0,
@@ -188,7 +188,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
},
"esp32-eth": {
@@ -250,7 +250,7 @@
"desktop-windows": {
"tick_us": [
111,
- 532
+ 579
],
"free_heap": [
0,
@@ -262,7 +262,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
},
"esp32-eth": {
diff --git a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json
index e5461ca3..612971e1 100644
--- a/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json
+++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json
@@ -108,7 +108,7 @@
"desktop-windows": {
"tick_us": [
3,
- 6
+ 12
],
"free_heap": [
0,
@@ -120,7 +120,7 @@
],
"at": [
"2026-06-07",
- "2026-06-07"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
index 636a3f4f..22a7c53d 100644
--- a/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
+++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json
@@ -108,7 +108,7 @@
"desktop-windows": {
"tick_us": [
225,
- 445
+ 802
],
"free_heap": [
0,
@@ -120,7 +120,7 @@
],
"at": [
"2026-06-07",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json
index 65386640..3f713a53 100644
--- a/test/scenarios/light/scenario_modifier_chain.json
+++ b/test/scenarios/light/scenario_modifier_chain.json
@@ -120,7 +120,7 @@
"desktop-windows": {
"tick_us": [
13,
- 25
+ 49
],
"free_heap": [
0,
@@ -132,7 +132,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -167,7 +167,7 @@
"desktop-windows": {
"tick_us": [
12,
- 20
+ 42
],
"free_heap": [
0,
@@ -179,7 +179,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -212,7 +212,7 @@
"desktop-windows": {
"tick_us": [
42,
- 58
+ 130
],
"free_heap": [
0,
@@ -224,7 +224,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -259,7 +259,7 @@
"desktop-windows": {
"tick_us": [
69,
- 138
+ 224
],
"free_heap": [
0,
@@ -271,7 +271,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json
index a0ac2db2..047d2fad 100644
--- a/test/scenarios/light/scenario_modifier_swap.json
+++ b/test/scenarios/light/scenario_modifier_swap.json
@@ -242,7 +242,7 @@
"desktop-windows": {
"tick_us": [
14,
- 20
+ 50
],
"free_heap": [
0,
@@ -254,7 +254,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -364,7 +364,7 @@
"desktop-windows": {
"tick_us": [
41,
- 61
+ 131
],
"free_heap": [
0,
@@ -376,7 +376,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -486,7 +486,7 @@
"desktop-windows": {
"tick_us": [
13,
- 22
+ 47
],
"free_heap": [
0,
@@ -498,7 +498,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json
index be32f412..88d404c5 100644
--- a/test/scenarios/light/scenario_perf_full.json
+++ b/test/scenarios/light/scenario_perf_full.json
@@ -159,7 +159,7 @@
"desktop-windows": {
"tick_us": [
3,
- 5
+ 15
],
"free_heap": [
0,
@@ -171,7 +171,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -266,7 +266,7 @@
"desktop-windows": {
"tick_us": [
3,
- 5
+ 15
],
"free_heap": [
0,
@@ -278,7 +278,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -373,7 +373,7 @@
"desktop-windows": {
"tick_us": [
3,
- 4
+ 15
],
"free_heap": [
0,
@@ -385,7 +385,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -478,7 +478,7 @@
"desktop-windows": {
"tick_us": [
1,
- 1
+ 4
],
"free_heap": [
0,
@@ -490,7 +490,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -590,7 +590,7 @@
"desktop-windows": {
"tick_us": [
3,
- 5
+ 15
],
"free_heap": [
0,
@@ -602,7 +602,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -695,7 +695,7 @@
"desktop-windows": {
"tick_us": [
3,
- 9
+ 16
],
"free_heap": [
0,
@@ -707,7 +707,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -811,7 +811,7 @@
"desktop-windows": {
"tick_us": [
3,
- 4
+ 15
],
"free_heap": [
0,
@@ -823,7 +823,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -928,7 +928,7 @@
"desktop-windows": {
"tick_us": [
3,
- 5
+ 15
],
"free_heap": [
0,
@@ -940,7 +940,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1045,7 +1045,7 @@
"desktop-windows": {
"tick_us": [
3,
- 12
+ 15
],
"free_heap": [
0,
@@ -1057,7 +1057,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1172,7 +1172,7 @@
"desktop-windows": {
"tick_us": [
3,
- 5
+ 15
],
"free_heap": [
0,
@@ -1184,7 +1184,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1394,7 +1394,7 @@
"desktop-windows": {
"tick_us": [
49,
- 87
+ 237
],
"free_heap": [
0,
@@ -1406,7 +1406,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1505,7 +1505,7 @@
"desktop-windows": {
"tick_us": [
196,
- 276
+ 933
],
"free_heap": [
0,
@@ -1517,7 +1517,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1624,7 +1624,7 @@
"desktop-windows": {
"tick_us": [
9,
- 13
+ 27
],
"free_heap": [
0,
@@ -1636,7 +1636,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1735,7 +1735,7 @@
"desktop-windows": {
"tick_us": [
36,
- 52
+ 104
],
"free_heap": [
0,
@@ -1747,7 +1747,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1846,7 +1846,7 @@
"desktop-windows": {
"tick_us": [
145,
- 260
+ 423
],
"free_heap": [
0,
@@ -1858,7 +1858,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -1957,7 +1957,7 @@
"desktop-windows": {
"tick_us": [
592,
- 851
+ 1668
],
"free_heap": [
0,
@@ -1969,7 +1969,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -2076,7 +2076,7 @@
"desktop-windows": {
"tick_us": [
2,
- 5
+ 7
],
"free_heap": [
0,
@@ -2088,7 +2088,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -2187,7 +2187,7 @@
"desktop-windows": {
"tick_us": [
9,
- 16
+ 28
],
"free_heap": [
0,
@@ -2199,7 +2199,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -2298,7 +2298,7 @@
"desktop-windows": {
"tick_us": [
36,
- 55
+ 108
],
"free_heap": [
0,
@@ -2310,7 +2310,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json
index f3628f34..e9222856 100644
--- a/test/scenarios/light/scenario_perf_light.json
+++ b/test/scenarios/light/scenario_perf_light.json
@@ -174,7 +174,7 @@
"desktop-windows": {
"tick_us": [
3,
- 6
+ 16
],
"free_heap": [
0,
@@ -186,7 +186,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -280,7 +280,7 @@
"desktop-windows": {
"tick_us": [
1,
- 2
+ 4
],
"free_heap": [
0,
@@ -292,7 +292,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -378,7 +378,7 @@
"desktop-windows": {
"tick_us": [
1,
- 1
+ 4
],
"free_heap": [
0,
@@ -390,7 +390,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -483,7 +483,7 @@
"desktop-windows": {
"tick_us": [
2,
- 3
+ 7
],
"free_heap": [
0,
@@ -495,7 +495,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -594,7 +594,7 @@
"desktop-windows": {
"tick_us": [
9,
- 16
+ 27
],
"free_heap": [
0,
@@ -606,7 +606,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
@@ -705,7 +705,7 @@
"desktop-windows": {
"tick_us": [
35,
- 46
+ 106
],
"free_heap": [
0,
@@ -717,7 +717,7 @@
],
"at": [
"2026-07-08",
- "2026-07-08"
+ "2026-07-13"
]
}
}
diff --git a/test/unit/core/unit_BleProvisioningModule.cpp b/test/unit/core/unit_BleProvisioningModule.cpp
new file mode 100644
index 00000000..72216f85
--- /dev/null
+++ b/test/unit/core/unit_BleProvisioningModule.cpp
@@ -0,0 +1,125 @@
+// @module BleProvisioningModule
+
+#include "doctest.h"
+#include "core/BleProvisioningModule.h"
+
+#include
+#include
+#include
+
+namespace {
+
+struct FakeBleState {
+ uint32_t now = 0;
+ bool startResult = true;
+ unsigned startCalls = 0;
+ unsigned stopCalls = 0;
+ char* ssidOut = nullptr;
+ size_t ssidOutLen = 0;
+ char* passwordOut = nullptr;
+ size_t passwordOutLen = 0;
+ std::atomic* ready = nullptr;
+};
+
+FakeBleState fake;
+
+uint32_t fakeMillis() { return fake.now; }
+const char* fakeChipModel() { return "desktop-test"; }
+
+bool fakeStart(const char* /*deviceName*/, const char* /*chipModel*/, const char* /*version*/,
+ char* ssidOut, size_t ssidOutLen,
+ char* passwordOut, size_t passwordOutLen,
+ std::atomic* ready,
+ char* statusBuf, size_t statusBufLen) {
+ fake.startCalls++;
+ fake.ssidOut = ssidOut;
+ fake.ssidOutLen = ssidOutLen;
+ fake.passwordOut = passwordOut;
+ fake.passwordOutLen = passwordOutLen;
+ fake.ready = ready;
+ std::snprintf(statusBuf, statusBufLen, "BLE test listener");
+ return fake.startResult;
+}
+
+void fakeStop() { fake.stopCalls++; }
+
+const mm::BleProvisioningRuntime runtime{
+ fakeMillis,
+ fakeChipModel,
+ fakeStart,
+ fakeStop,
+};
+
+struct FakeGuard {
+ FakeGuard() { fake = {}; }
+ ~FakeGuard() { fake = {}; }
+};
+
+} // namespace
+
+TEST_CASE("BLE provisioning advertises only during Network AP fallback") {
+ FakeGuard guard;
+ mm::NetworkModule network;
+ mm::BleProvisioningModule ble;
+ ble.setNetworkModule(&network);
+ ble.setRuntime(&runtime);
+
+ ble.setup();
+ CHECK(fake.startCalls == 0);
+
+ network.setProvisioningModeForTest(true);
+ fake.now = 5000;
+ ble.tick1s();
+ CHECK(fake.startCalls == 1);
+
+ network.setProvisioningModeForTest(false);
+ ble.tick1s();
+ CHECK(fake.stopCalls == 1);
+}
+
+TEST_CASE("BLE provisioning retries failed listener startup every five seconds") {
+ FakeGuard guard;
+ fake.startResult = false;
+ mm::NetworkModule network;
+ network.setProvisioningModeForTest(true);
+ mm::BleProvisioningModule ble;
+ ble.setNetworkModule(&network);
+ ble.setRuntime(&runtime);
+
+ ble.setup();
+ CHECK(fake.startCalls == 1);
+
+ fake.now = 4999;
+ ble.tick1s();
+ CHECK(fake.startCalls == 1);
+
+ fake.now = 5000;
+ ble.tick1s();
+ CHECK(fake.startCalls == 2);
+}
+
+TEST_CASE("BLE provisioning publishes credentials to Network and clears the password") {
+ FakeGuard guard;
+ mm::NetworkModule network;
+ network.setProvisioningModeForTest(true);
+ mm::BleProvisioningModule ble;
+ ble.setNetworkModule(&network);
+ ble.setRuntime(&runtime);
+ ble.setup();
+
+ REQUIRE(fake.ssidOut != nullptr);
+ REQUIRE(fake.passwordOut != nullptr);
+ REQUIRE(fake.ready != nullptr);
+ std::snprintf(fake.ssidOut, fake.ssidOutLen, "homeAP");
+ std::snprintf(fake.passwordOut, fake.passwordOutLen, "secret123");
+ fake.ready->store(true, std::memory_order_release);
+
+ CHECK_FALSE(network.dirty());
+ ble.tick1s();
+ CHECK(network.dirty());
+ CHECK_FALSE(fake.ready->load(std::memory_order_acquire));
+ CHECK(fake.passwordOut[0] == 0);
+
+ ble.release();
+ CHECK(fake.stopCalls == 1);
+}