From 74c40ee9864c30b14b85c5cece18bfd28784e398 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 18 Aug 2026 08:19:02 +0200 Subject: [PATCH] Add BLE Wi-Fi provisioning alongside the setup SoftAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR --- Software/components/net/CMakeLists.txt | 3 +- .../net/include/net/BleProvisioning.hpp | 50 ++++++ .../components/net/include/net/NetError.hpp | 1 + .../components/net/src/BleProvisioning.cpp | 159 ++++++++++++++++++ Software/components/net/src/NetBootstrap.cpp | 10 ++ Software/sdkconfig.defaults | 7 + 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Software/components/net/include/net/BleProvisioning.hpp create mode 100644 Software/components/net/src/BleProvisioning.cpp diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index ef5c0f9..1a163f4 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -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) diff --git a/Software/components/net/include/net/BleProvisioning.hpp b/Software/components/net/include/net/BleProvisioning.hpp new file mode 100644 index 0000000..84a7807 --- /dev/null +++ b/Software/components/net/include/net/BleProvisioning.hpp @@ -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 + +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 +start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity); + +} // namespace net::ble_provisioning diff --git a/Software/components/net/include/net/NetError.hpp b/Software/components/net/include/net/NetError.hpp index 12bd678..5405ca8 100644 --- a/Software/components/net/include/net/NetError.hpp +++ b/Software/components/net/include/net/NetError.hpp @@ -43,6 +43,7 @@ enum class NetError { StoreSaveFailed, CredentialsNotFound, WifiScanFailed, + BleProvisioningFailed, }; } // namespace net diff --git a/Software/components/net/src/BleProvisioning.cpp b/Software/components/net/src/BleProvisioning.cpp new file mode 100644 index 0000000..9552c12 --- /dev/null +++ b/Software/components/net/src/BleProvisioning.cpp @@ -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 +#include +#include + +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 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(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(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 +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 diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index 31903ac..8bc6f55 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -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(deviceIdentity.softApSsid().size()), deviceIdentity.softApSsid().data()); diff --git a/Software/sdkconfig.defaults b/Software/sdkconfig.defaults index bac4e06..8d67b87 100644 --- a/Software/sdkconfig.defaults +++ b/Software/sdkconfig.defaults @@ -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