Add BLE Wi-Fi provisioning alongside the setup SoftAP

Wraps ESP-IDF's official wifi_provisioning manager (BLE transport,
protocomm Security1, NimBLE host) so a phone can join the device to
Wi-Fi over the ESP32-S3's own onboard BLE radio, without first
connecting to the 192.168.4.1 SoftAP. Chosen over a custom GATT
service specifically so the existing generic "ESP BLE Provisioning"
iOS/Android apps work today, before the dedicated DigiRadio app
exists — same standard protocol either app would speak.

net::ble_provisioning::start() is additive, not a replacement: it
runs next to the current SoftAP + POST /api/wifi HTTP route inside
NetBootstrap's startSetupMode(), and failing to start it is
non-fatal (same pattern already used there for the SigmaStudio TCP
bridge) — SoftAP setup keeps working either way. Proof-of-possession
is the device's own serial number (same source as the SoftAP SSID),
so pairing requires reading it off the unit rather than being wide
open. On WIFI_PROV_CRED_SUCCESS the received wifi_sta_config_t is
converted to the same core::WifiCredentials type the HTTP handler
uses and saved through the same ISecureStore, then the device
reboots into STA mode — one persistence path regardless of which
transport provisioned it.

BT1035 is unaffected: it's a separate UART-attached classic
Bluetooth module for A2DP output. This uses the ESP32-S3's
independent internal BLE controller, switched to NimBLE (smaller
footprint than Bluedroid, the only host stack needed for a single
peripheral-role GATT service). App binary still has 33% free flash
after pulling in wifi_provisioning/protocomm/NimBLE.

Verified: idf.py build, doxygen (0 warnings), check-manual-sync,
check_si4684_blobs, ctest (19/19) all green. Not yet tested with a
real BLE provisioning app or on hardware — board is disconnected
this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR
This commit is contained in:
2026-08-18 08:19:02 +02:00
co-authored by Claude Sonnet 5
parent e8f79c4b79
commit 74c40ee986
6 changed files with 229 additions and 1 deletions
+2 -1
View File
@@ -7,9 +7,10 @@ idf_component_register(
"src/SigmaStudioTcpServer.cpp"
"src/WifiScanner.cpp"
"src/NetBootstrap.cpp"
"src/BleProvisioning.cpp"
INCLUDE_DIRS "include"
EMBED_FILES "www/index.html.gz"
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns lwip secure_store tuner audio bluetooth station integration ota bt1035 adau1701 webradio
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns lwip secure_store tuner audio bluetooth station integration ota bt1035 adau1701 webradio wifi_provisioning
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -0,0 +1,50 @@
/**
* @file BleProvisioning.hpp
* @brief BLE GATT Wi-Fi provisioning, additive alongside the setup SoftAP.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* @author Michele Bigi
* @date 2026-08-18
*/
#pragma once
#include "core/DeviceIdentity.hpp"
#include "core/ISecureStore.hpp"
#include "net/NetError.hpp"
#include <expected>
namespace net::ble_provisioning {
/**
* @brief start — start the ESP-IDF wifi_provisioning manager over BLE.
*
* @dname start
* @param store Secure store the received credentials are saved
* to (same store POST /api/wifi writes to).
* @param deviceIdentity Supplies the BLE advertising name (softApSsid)
* and the proof-of-possession string (serialNumber).
* @return Ok once provisioning is advertising, or NetError::BleProvisioningFailed.
* @pubstate Starts the onboard ESP32-S3 BLE radio (independent of the BT1035
* UART module) and a process-lifetime wifi_provisioning manager
* singleton. On a successful join, saves credentials to store and
* reboots, mirroring wifiPostHandler's POST /api/wifi behaviour.
* Runs alongside the existing SoftAP + HTTP provisioning route,
* not instead of it — either path can complete setup.
*
* @author Michele Bigi
* @date 2026-08-18
*/
[[nodiscard]] std::expected<void, NetError>
start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity);
} // namespace net::ble_provisioning
@@ -43,6 +43,7 @@ enum class NetError {
StoreSaveFailed,
CredentialsNotFound,
WifiScanFailed,
BleProvisioningFailed,
};
} // namespace net
@@ -0,0 +1,159 @@
/**
* @file BleProvisioning.cpp
* @brief BleProvisioning implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* @author Michele Bigi
* @date 2026-08-18
*/
#include "net/BleProvisioning.hpp"
#include "core/Secret.hpp"
#include "core/WifiCredentials.hpp"
#include "core/WifiSsid.hpp"
#include "esp_log.h"
#include "esp_wifi_types.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning/manager.h"
#include "wifi_provisioning/scheme_ble.h"
#include <cstring>
#include <optional>
#include <string>
namespace net::ble_provisioning {
namespace {
constexpr char kTag[] = "BleProvisioning";
constexpr unsigned kRebootDelaySec = 3;
// wifi_prov_mgr_init's callback is a plain C function pointer, so the store
// and the credentials received mid-handshake are kept in module statics —
// this mirrors the manager's own singleton lifetime (one instance for the
// life of the process, same as NetBootstrap's other network resources).
core::ISecureStore* gStore = nullptr;
std::optional<wifi_sta_config_t> gPendingCreds;
void rebootTask(void* arg)
{
(void)arg;
vTaskDelay(pdMS_TO_TICKS(kRebootDelaySec * 1000));
esp_restart();
}
/** wifi_sta_config_t::ssid/password are fixed-size byte arrays, zero-padded
* but not guaranteed null-terminated when exactly full length. */
[[nodiscard]] std::string_view boundedView(const std::uint8_t* bytes,
std::size_t maxLen) noexcept
{
const auto* chars = reinterpret_cast<const char*>(bytes);
return std::string_view(chars, strnlen(chars, maxLen));
}
void saveAndReboot()
{
if (gStore == nullptr || !gPendingCreds.has_value()) {
ESP_LOGW(kTag, "credentials success event with no pending creds — "
"not saving");
return;
}
const auto& cfg = *gPendingCreds;
const std::string_view ssidRaw =
boundedView(cfg.ssid, sizeof(cfg.ssid));
const std::string_view passwordRaw =
boundedView(cfg.password, sizeof(cfg.password));
if (!core::WifiSsid::isValid(ssidRaw) ||
!core::WifiCredentials::isPasswordValid(passwordRaw)) {
ESP_LOGW(kTag, "BLE-provisioned credentials failed local validation "
"— not saving");
return;
}
const core::WifiCredentials creds{core::WifiSsid(ssidRaw),
core::Secret(std::string(passwordRaw))};
if (!gStore->saveWifiCredentials(creds)) {
ESP_LOGE(kTag, "failed to persist BLE-provisioned credentials");
return;
}
ESP_LOGI(kTag, "Wi-Fi joined via BLE provisioning — rebooting into STA");
xTaskCreate(rebootTask, "ble_prov_reboot", 2048, nullptr, 5, nullptr);
}
void provEventHandler(void* /*user_data*/, wifi_prov_cb_event_t event,
void* data)
{
switch (event) {
case WIFI_PROV_CRED_RECV:
if (data != nullptr) {
gPendingCreds = *static_cast<const wifi_sta_config_t*>(data);
ESP_LOGI(kTag, "credentials received over BLE");
}
break;
case WIFI_PROV_CRED_FAIL:
ESP_LOGW(kTag, "BLE-provisioned Wi-Fi join failed — waiting for "
"the app to retry");
gPendingCreds.reset();
break;
case WIFI_PROV_CRED_SUCCESS:
saveAndReboot();
break;
default:
break;
}
}
} // namespace
std::expected<void, NetError>
start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity)
{
gStore = &store;
gPendingCreds.reset();
// Copies kept for the lifetime of provisioning: wifi_prov_mgr_start_
// provisioning only borrows these pointers, it does not take ownership.
static const std::string serviceName(deviceIdentity.softApSsid());
static const std::string pop(deviceIdentity.serialNumber());
const wifi_prov_mgr_config_t config{
.scheme = wifi_prov_scheme_ble,
.scheme_event_handler = WIFI_PROV_SCHEME_BLE_EVENT_HANDLER_FREE_BTDM,
.app_event_handler = {.event_cb = provEventHandler,
.user_data = nullptr},
};
if (wifi_prov_mgr_init(config) != ESP_OK) {
ESP_LOGE(kTag, "wifi_prov_mgr_init failed");
return std::unexpected(NetError::BleProvisioningFailed);
}
if (wifi_prov_mgr_start_provisioning(WIFI_PROV_SECURITY_1, pop.c_str(),
serviceName.c_str(),
nullptr) != ESP_OK) {
ESP_LOGE(kTag, "wifi_prov_mgr_start_provisioning failed");
wifi_prov_mgr_deinit();
return std::unexpected(NetError::BleProvisioningFailed);
}
ESP_LOGI(kTag,
"BLE provisioning advertising as %s (proof-of-possession: "
"device serial number)",
serviceName.c_str());
return {};
}
} // namespace net::ble_provisioning
@@ -23,6 +23,7 @@
#include "esp_netif.h"
#include "esp_wifi.h"
#include "audio/AudioService.hpp"
#include "net/BleProvisioning.hpp"
#include "bluetooth/BluetoothService.hpp"
#include "secure_store/NvsPlatformInit.hpp"
#include "station/StationService.hpp"
@@ -131,6 +132,15 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
"without it");
}
// Additive: a phone can provision over BLE (no need to first join the
// SoftAP) while the HTTP route above keeps working as before. Non-fatal
// — setup mode is still fully usable via SoftAP if this fails.
if (auto bleResult = ble_provisioning::start(store, deviceIdentity);
!bleResult) {
ESP_LOGW(kTag, "BLE provisioning failed to start — SoftAP setup "
"still available");
}
ESP_LOGI(kTag, "setup mode ready — SSID %.*s",
static_cast<int>(deviceIdentity.softApSsid().size()),
deviceIdentity.softApSsid().data());
+7
View File
@@ -28,3 +28,10 @@ CONFIG_COMPILER_CXX_STD=23
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# Onboard BLE for wifi_provisioning (net/BleProvisioning.cpp) — the ESP32-S3's
# own radio, independent of the BT1035 UART module which stays classic-A2DP-only.
# NimBLE instead of Bluedroid: smaller flash/RAM footprint, and the only host
# stack this firmware needs is a single GATT peripheral role for provisioning.
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y