Skip to content

Improve Wi-Fi onboarding and BLE provisioning#46

Draft
Funkelfetisch wants to merge 3 commits into
MoonModules:mainfrom
Funkelfetisch:codex/universal-ble-provisioning
Draft

Improve Wi-Fi onboarding and BLE provisioning#46
Funkelfetisch wants to merge 3 commits into
MoonModules:mainfrom
Funkelfetisch:codex/universal-ble-provisioning

Conversation

@Funkelfetisch

@Funkelfetisch Funkelfetisch commented Jul 10, 2026

Copy link
Copy Markdown

Summary

  • add a generic BLE Provisioning Network child backed by Espressif network_provisioning
  • enable BLE provisioning for native-radio WiFi ESP32 variants behind Kconfig and an sdkconfig fragment
  • apply web/installer Wi-Fi credentials explicitly, delay connection attempts safely, and retry dropped STA connections before AP fallback
  • surface provisioning and STA diagnostics while unsupported targets stay stubbed

Validation

  • git diff --check origin/main..HEAD
  • moondeck/check/check_specs.py
  • moondeck/check/check_platform_boundary.py
  • product-specific string scan on the PR diff
  • moondeck/build/build_esp32.py --firmware esp32s3-n16r8
  • esp32s3-n16r8 image: 0x1cd430, 55% app partition free

Not Run

  • BLE provisioning from a real phone/client to an ESP32 device
  • Desktop CMake/ctest: CMake is not available in this shell

Summary by CodeRabbit

  • New Features
    • Added optional BLE Wi‑Fi provisioning for supported ESP32 devices.
    • Added BLE provisioning status information and setup controls.
    • Wi‑Fi connections now support delayed credential application, manual connection, automatic retries, and last-disconnect visibility.
  • Bug Fixes
    • Improved recovery from temporary Wi‑Fi disconnections before switching to access-point mode.
  • Documentation
    • Expanded provisioning documentation with separate USB-serial and BLE sections.
  • Tests
    • Added coverage for delayed and manual Wi‑Fi connection flows.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cd719b68-2ba0-480b-8217-103f62f5086a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Funkelfetisch Funkelfetisch changed the title Add generic BLE WiFi provisioning Improve Wi-Fi onboarding and BLE provisioning Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@esp32/sdkconfig.defaults.ble-provisioning`:
- Around line 9-11: Update makeIdentity() in the ESP32 BLE provisioning
implementation so Security 1 uses a unique, non-public per-device secret rather
than the MAC suffix mac[3..5]. Preserve the existing BLE service-name identity
separately, and ensure the generated secret is delivered through the established
out-of-band provisioning mechanism.

In `@src/core/NetworkModule.h`:
- Around line 325-338: Update the empty-SSID branch in onUpdate() to ensure AP
mode is actually started, reusing the existing behavior from
maybeApplyPendingWifiCredentials(): transition via startAP() when state_ is not
already State::AP, then report the status. Preserve clearing
wifiCredentialApplyPending_ and avoid restarting AP when it is already active.

In `@src/platform/esp32/platform_esp32_ble_provisioning.cpp`:
- Around line 23-37: Make the BleProvisioningState fields started and
initialized atomic, then update all accesses in bleProvEventCb,
bleProvisioningInit, and bleProvisioningStop to use atomic-safe reads and
writes. Preserve the existing branching and lifecycle behavior while ensuring
concurrent provisioning callbacks and main-thread teardown cannot race.
- Around line 66-75: Update makeIdentity so g_bleProv.pop is derived from a
private per-device secret or another non-advertised provisioning source, rather
than mac[3..5] used in serviceName. Keep the BLE service name generation
unchanged, and ensure the PoP remains available through the intended secure
channel or device labeling without being recoverable from the advertised name.

In `@test/unit/core/unit_NetworkModule.cpp`:
- Around line 66-117: Extend the NetworkModule regression coverage with a test
that sets a non-empty SSID, connects, then clears the SSID through
scheduler.setControl and verifies the module enters AP state using its state/API
assertion rather than only checking status text. Keep the test clock setup and
ClockGuard cleanup pattern, and ensure the assertion matches the specification
in docs/moonmodules/.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bb426d11-8e5c-4171-9181-387836ea56c6

📥 Commits

Reviewing files that changed from the base of the PR and between 0c289ab and 7fb6aeb.

⛔ Files ignored due to path filters (1)
  • moondeck/build/build_esp32.py is excluded by !**/build/**
📒 Files selected for processing (15)
  • docs/moonmodules/core/services.md
  • esp32/main/CMakeLists.txt
  • esp32/main/Kconfig.projbuild
  • esp32/main/idf_component.yml
  • esp32/sdkconfig.defaults.ble-provisioning
  • src/core/BleProvisioningModule.h
  • src/core/NetworkModule.h
  • src/main.cpp
  • src/platform/desktop/platform_config.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_config.h
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/esp32/platform_esp32_ble_provisioning.cpp
  • src/platform/platform.h
  • test/unit/core/unit_NetworkModule.cpp

Comment on lines +9 to +11
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

For Espressif network_provisioning Security 1, confirm that the proof of possession must remain a unique per-device secret and assess whether a MAC-derived value provides authentication.

💡 Result:

For Espressif's Security 1 provisioning scheme, the proof of possession (PoP) is intended to be a unique per-device secret [1][2]. It functions as a security measure to ensure that only an authorized user—typically the person in physical possession of the device—can provision it [1][2]. While the protocol allows for a "No Auth" mode (Null PoP) where the shared key is derived through key exchange only [1][2], the use of a unique PoP is the recommended practice for authentication [1][2]. Regarding whether a MAC-derived value provides authentication, it is important to distinguish between the MAC address and the PoP itself. The Security 1 protocol uses an X25519 key exchange to derive a shared secret [1][3][4]. If a PoP is provided, this shared secret is then combined (specifically, XORed) with the SHA-256 hash of the PoP string [4]. This PoP string must be a secret known to both the device and the provisioning client to successfully authenticate the session [1][2][4]. Using a MAC address as a "derived value" (or as the PoP itself) is generally insecure for authentication purposes because MAC addresses are typically broadcast in cleartext and are not secret [1]. Official documentation emphasizes the need for a "unique per-device secret" [1][2], which should be a distinct value rather than a device's publicly available identifier, to ensure the security of the provisioning process [1]. If you rely solely on a device's public MAC address, you do not achieve the intended authentication security, as it fails to serve as a proof of possession of a secret [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the referenced file and related provisioning code.
git ls-files 'esp32/*' | sed -n '1,200p'
printf '\n--- search ---\n'
rg -n -S 'ble-provisioning|PoP|proof of possession|mac suffix|MAC suffix|service name|service_name|security version 1|SECURITY_VERSION_1|protocomm' esp32 . --hidden --glob '!**/.git/**'
printf '\n--- file ---\n'
cat -n esp32/sdkconfig.defaults.ble-provisioning

Repository: MoonModules/projectMM

Length of output: 4228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,260p' src/platform/esp32/platform_esp32_ble_provisioning.cpp
printf '\n--- docs excerpt ---\n'
sed -n '1,120p' docs/moonmodules/core/services.md

Repository: MoonModules/projectMM

Length of output: 13889


Replace the MAC-derived PoP with a real secret. In src/platform/esp32/platform_esp32_ble_provisioning.cpp, makeIdentity() sets both the BLE service name and Security 1 PoP from mac[3..5], so the value is public and guessable. Generate a unique per-device secret instead and deliver it out of band.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esp32/sdkconfig.defaults.ble-provisioning` around lines 9 - 11, Update
makeIdentity() in the ESP32 BLE provisioning implementation so Security 1 uses a
unique, non-public per-device secret rather than the MAC suffix mac[3..5].
Preserve the existing BLE service-name identity separately, and ensure the
generated secret is delivered through the established out-of-band provisioning
mechanism.

Comment thread src/core/NetworkModule.h
Comment on lines +325 to +338
void onUpdate(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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

onUpdate()'s empty-SSID branch never actually starts AP — status text lies about radio state.

Compare with maybeApplyPendingWifiCredentials() (line 823-826), whose empty-ssid branch calls startAP() when not already in State::AP. The onUpdate() branch at lines 333-338 does neither that nor delegate to it — it just clears wifiCredentialApplyPending_ and prints "WiFi SSID empty; AP active" without transitioning state_. Since wifiCredentialApplyPending_ is always false by the time this branch runs (and stays false), the delayed-apply path in maybeApplyPendingWifiCredentials() never fires either — so clearing the SSID field via the web UI (or via any code path routed through onUpdate) leaves the device in whatever state it was already in (e.g. still associated as ConnectedSta) while displaying a status claiming AP is active.

🐛 Proposed fix
             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;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void onUpdate(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;
}
void onUpdate(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;
if (state_ != State::AP) startAP();
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/NetworkModule.h` around lines 325 - 338, Update the empty-SSID
branch in onUpdate() to ensure AP mode is actually started, reusing the existing
behavior from maybeApplyPendingWifiCredentials(): transition via startAP() when
state_ is not already State::AP, then report the status. Preserve clearing
wifiCredentialApplyPending_ and avoid restarting AP when it is already active.

Comment on lines +23 to +37
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<bool>* ready = nullptr;
char* statusBuf = nullptr;
size_t statusBufLen = 0;
bool initialized = false;
bool started = false;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Data race on g_bleProv.started/initialized across threads.

bleProvEventCb (dispatched on the provisioning manager's own task, per NETWORK_PROV_END/NETWORK_PROV_DEINIT at lines 113-119) mutates the plain bool started/initialized fields of the global g_bleProv, while bleProvisioningInit()/bleProvisioningStop() (called from the module's main-thread loop1s()/teardown()) read and write the very same fields with no atomics or lock. This is a data race — most likely to bite if teardown() calls bleProvisioningStop() at the same moment provisioning legitimately ends (NETWORK_PROV_END), since both paths branch on started/initialized to decide whether to call network_prov_mgr_stop_provisioning() vs deinit().

Make these fields std::atomic<bool> (or guard with a small mutex) to remove the race.

🔒 Proposed fix
-    bool initialized = false;
-    bool started = false;
+    std::atomic<bool> initialized{false};
+    std::atomic<bool> started{false};

Also applies to: 109-120, 128-194

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/esp32/platform_esp32_ble_provisioning.cpp` around lines 23 - 37,
Make the BleProvisioningState fields started and initialized atomic, then update
all accesses in bleProvEventCb, bleProvisioningInit, and bleProvisioningStop to
use atomic-safe reads and writes. Preserve the existing branching and lifecycle
behavior while ensuring concurrent provisioning callbacks and main-thread
teardown cannot race.

Comment on lines +66 to +75
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]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

PoP is derived from the same bytes shown in the public BLE service name — provides no real proof-of-possession.

makeIdentity() builds both serviceName and pop from the identical mac[3..5] bytes: makeIdentity computes the service name and PoP from the same three MAC bytes (see lines 71-74). Since the service name is broadcast in the BLE advertisement so a phone/attacker can even find the device, anyone who reads that name can read the PoP suffix directly — Security1's "proof of possession" handshake ends up authenticating with a secret that is, by construction, already public. This defeats the purpose of choosing NETWORK_PROV_SECURITY_1 at all: any nearby attacker can push arbitrary Wi-Fi credentials or read the ones sent by the legitimate owner.

Derive the PoP from something that is not also embedded in the advertised name (e.g. a different portion of a per-device secret, or a value printed on a label/shown over the already-secure USB-serial Improv channel), so the security session's secret isn't self-defeating.

Also applies to: 166-171

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/esp32/platform_esp32_ble_provisioning.cpp` around lines 66 - 75,
Update makeIdentity so g_bleProv.pop is derived from a private per-device secret
or another non-advertised provisioning source, rather than mac[3..5] used in
serviceName. Keep the BLE service name generation unchanged, and ensure the PoP
remains available through the intended secure channel or device labeling without
being recoverable from the advertised name.

Comment on lines +66 to +117
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->loop1s();

CHECK_FALSE(net->wifiCredentialApplyPendingForTest());
CHECK(std::strcmp(net->status(), "No network") == 0);
scheduler.teardown();
}

// 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.teardown();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tests correctly use the setTestNowMs/ClockGuard seam and match current desktop-stub behavior. Given the empty-SSID bug flagged in NetworkModule.h (onUpdate never calls startAP() when SSID is cleared), consider adding a regression test here: write ssid to a non-empty value, connect, then write ssid to "" via scheduler.setControl and assert the state actually reflects AP (not just a status string). Happy to draft this test alongside the fix if useful.

As per path instructions, "Verify tests cover edge cases and match the specifications in docs/moonmodules/."

🧰 Tools
🪛 Cppcheck (2.21.0)

[style] 101-101: The function 'appearsInUi' is never used.

(unusedFunction)


[style] 113-113: The function 'lastSavedStr' is never used.

(unusedFunction)


[style] 104-104: The function 'setSystemModule' is never used.

(unusedFunction)


[style] 73-73: The function 'moduleCount' is never used.

(unusedFunction)


[style] 74-74: The function 'module' is never used.

(unusedFunction)


[style] 95-95: The function 'instance' is never used.

(unusedFunction)


[style] 85-85: The function 'acceptsChildRoles' is never used.

(unusedFunction)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/core/unit_NetworkModule.cpp` around lines 66 - 117, Extend the
NetworkModule regression coverage with a test that sets a non-empty SSID,
connects, then clears the SSID through scheduler.setControl and verifies the
module enters AP state using its state/API assertion rather than only checking
status text. Keep the test clock setup and ClockGuard cleanup pattern, and
ensure the assertion matches the specification in docs/moonmodules/.

Source: Path instructions

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant