provisioning.cpp
1 #include "provisioning.h" 2 #include <config.h> 3 #include <identity.h> 4 5 #include <Arduino.h> 6 #include <Preferences.h> 7 #include <WiFi.h> 8 #include <atomic> 9 #include <esp_wifi.h> 10 11 namespace { 12 13 bool clear_namespace(const char *name_space) { 14 Preferences prefs; 15 if (!prefs.begin(name_space, false)) 16 return false; 17 prefs.clear(); 18 prefs.end(); 19 return true; 20 } 21 22 bool open_namespace(const char *name_space, bool readonly, Preferences *prefs) { 23 return prefs && prefs->begin(name_space, readonly); 24 } 25 26 } // namespace 27 28 bool boot::provisioning::isEnabled(void) { return CERATINA_PROV_ENABLED; } 29 30 bool boot::provisioning::isProvisioned(void) { 31 wifi_config_t conf; 32 if (esp_wifi_get_config(WIFI_IF_STA, &conf) == ESP_OK && 33 conf.sta.ssid[0] != '\0') 34 return true; 35 36 #if defined(CONFIG_WIFI_SSID) && defined(CONFIG_WIFI_PASS) 37 if (strlen(CONFIG_WIFI_SSID) > 0) 38 return true; 39 #endif 40 41 return false; 42 } 43 44 void boot::provisioning::reset(void) { 45 clear_namespace(config::provisioning::NVS_NAMESPACE); 46 clear_namespace(config::wifi::NVS_NAMESPACE); 47 WiFi.disconnect(true, true); // erases ESP-IDF stored STA credentials 48 Serial.println(F("[prov] reset — credentials cleared")); 49 } 50 51 #if CERATINA_PROV_ENABLED 52 53 #include <ArduinoJson.h> 54 #include <BLEDevice.h> 55 #include <BLESecurity.h> 56 #include <BLEServer.h> 57 #include <BLEUtils.h> 58 #include <atomic> 59 60 #define PROV_CHAR_SSID_UUID "ceaa0002-b5a3-f393-e0a9-e50e24dcca9e" 61 #define PROV_CHAR_PASSWORD_UUID "ceaa0003-b5a3-f393-e0a9-e50e24dcca9e" 62 #define PROV_CHAR_STATUS_UUID "ceaa0004-b5a3-f393-e0a9-e50e24dcca9e" 63 64 static BLEServer *prov_server = nullptr; 65 static BLECharacteristic *status_char = nullptr; 66 static std::atomic<bool> prov_credentials_received = false; 67 static std::atomic<bool> prov_done = false; 68 69 static char prov_ssid[33] = {0}; 70 static char prov_pass[65] = {0}; 71 72 static void set_status(const char *status) { 73 if (status_char) { 74 status_char->setValue((uint8_t *)status, strlen(status)); 75 status_char->notify(); 76 } 77 Serial.printf("[prov] status: %s\n", status); 78 } 79 80 static void strip_trailing(char *buf, size_t *len) { 81 while (*len > 0 && (buf[*len - 1] == '\r' || buf[*len - 1] == '\n' || 82 buf[*len - 1] == ' ')) 83 buf[--(*len)] = '\0'; 84 } 85 86 class ProvisioningSsidCallbacks : public BLECharacteristicCallbacks { 87 void onWrite(BLECharacteristic *c) override { 88 size_t len = c->getLength(); 89 if (len == 0 || len > 32) 90 return; 91 memcpy(prov_ssid, c->getData(), len); 92 prov_ssid[len] = '\0'; 93 strip_trailing(prov_ssid, &len); 94 Serial.printf("[prov] SSID received: %s\n", prov_ssid); 95 } 96 }; 97 98 class ProvisioningPasswordCallbacks : public BLECharacteristicCallbacks { 99 void onWrite(BLECharacteristic *c) override { 100 size_t len = c->getLength(); 101 if (len > 64) 102 return; 103 memcpy(prov_pass, c->getData(), len); 104 prov_pass[len] = '\0'; 105 strip_trailing(prov_pass, &len); 106 Serial.println(F("[prov] password received")); 107 108 if (prov_ssid[0] != '\0') { 109 prov_credentials_received.store(true, std::memory_order_release); 110 } 111 } 112 }; 113 114 class ProvisioningConfigCallbacks : public BLECharacteristicCallbacks { 115 void onWrite(BLECharacteristic *c) override { 116 uint8_t *data = c->getData(); 117 size_t len = c->getLength(); 118 if (!data || len == 0) 119 return; 120 121 JsonDocument doc; 122 if (deserializeJson(doc, data, len) != DeserializationError::Ok) 123 return; 124 125 Preferences prefs; 126 if (!open_namespace(config::provisioning::NVS_NAMESPACE, false, &prefs)) 127 return; 128 for (JsonPair kv : doc.as<JsonObject>()) { 129 const char *val = kv.value().as<const char *>(); 130 if (val) { 131 prefs.putString(kv.key().c_str(), val); 132 Serial.printf("[prov] config: %s=%s\n", kv.key().c_str(), val); 133 } 134 } 135 prefs.end(); 136 } 137 }; 138 139 class ProvisioningServerCallbacks : public BLEServerCallbacks { 140 void onConnect(BLEServer *server) override { 141 Serial.println(F("[prov] client connected")); 142 set_status("connected"); 143 } 144 void onDisconnect(BLEServer *server) override { 145 Serial.println(F("[prov] client disconnected")); 146 if (!prov_done.load(std::memory_order_acquire)) { 147 server->startAdvertising(); 148 } 149 } 150 }; 151 152 void boot::provisioning::start(void) { 153 Serial.println(F("[prov] starting BLE provisioning")); 154 Serial.printf("[prov] passkey: %d\n", config::ble::PASSKEY); 155 156 prov_credentials_received.store(false, std::memory_order_relaxed); 157 prov_done.store(false, std::memory_order_relaxed); 158 prov_ssid[0] = '\0'; 159 prov_pass[0] = '\0'; 160 161 BLEDevice::init(config::HOSTNAME); 162 163 // Intentionally leaked — BLE stack owns these for device lifetime. 164 BLESecurity *pSecurity = new BLESecurity(); 165 pSecurity->setPassKey(true, config::ble::PASSKEY); 166 pSecurity->setCapability(ESP_IO_CAP_OUT); 167 pSecurity->setAuthenticationMode(true, true, true); 168 169 prov_server = BLEDevice::createServer(); 170 prov_server->setCallbacks( 171 new ProvisioningServerCallbacks()); // BLE stack owns 172 prov_server->advertiseOnDisconnect(true); 173 174 BLEService *svc = 175 prov_server->createService(config::provisioning::SERVICE_UUID); 176 177 BLECharacteristic *ssid_char = svc->createCharacteristic( 178 PROV_CHAR_SSID_UUID, BLECharacteristic::PROPERTY_WRITE | 179 BLECharacteristic::PROPERTY_WRITE_AUTHEN); 180 ssid_char->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); 181 ssid_char->setCallbacks(new ProvisioningSsidCallbacks()); // BLE stack owns 182 183 BLECharacteristic *pass_char = svc->createCharacteristic( 184 PROV_CHAR_PASSWORD_UUID, BLECharacteristic::PROPERTY_WRITE | 185 BLECharacteristic::PROPERTY_WRITE_AUTHEN); 186 pass_char->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); 187 pass_char->setCallbacks( 188 new ProvisioningPasswordCallbacks()); // BLE stack owns 189 190 BLECharacteristic *config_char = 191 svc->createCharacteristic(config::provisioning::CONFIG_UUID, 192 BLECharacteristic::PROPERTY_WRITE | 193 BLECharacteristic::PROPERTY_WRITE_AUTHEN); 194 config_char->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); 195 config_char->setCallbacks( 196 new ProvisioningConfigCallbacks()); // BLE stack owns 197 198 status_char = svc->createCharacteristic( 199 PROV_CHAR_STATUS_UUID, BLECharacteristic::PROPERTY_READ | 200 BLECharacteristic::PROPERTY_NOTIFY | 201 BLECharacteristic::PROPERTY_READ_AUTHEN); 202 status_char->setAccessPermissions(ESP_GATT_PERM_READ_ENC_MITM); 203 204 svc->start(); 205 set_status("waiting"); 206 207 BLEAdvertising *adv = BLEDevice::getAdvertising(); 208 adv->addServiceUUID(config::provisioning::SERVICE_UUID); 209 adv->setScanResponse(true); 210 BLEDevice::startAdvertising(); 211 212 Serial.printf("[prov] advertising as '%s', waiting for credentials...\n", 213 config::HOSTNAME); 214 215 while (!prov_credentials_received.load(std::memory_order_acquire)) { 216 delay(100); 217 } 218 219 set_status("connecting"); 220 221 WifiConnectCommand command = { 222 .request = 223 { 224 .ssid = prov_ssid, 225 .password = prov_pass, 226 .enable_ap_fallback = false, 227 }, 228 .result = {}, 229 }; 230 231 if (networking::wifi::connect(&command)) { 232 set_status("connected_wifi"); 233 Serial.printf("[prov] WiFi connected, heap: %u bytes free\n", 234 ESP.getFreeHeap()); 235 } else { 236 set_status("failed"); 237 Serial.println(F("[prov] WiFi connection failed")); 238 } 239 240 delay(2000); 241 prov_done.store(true, std::memory_order_release); 242 243 BLEDevice::deinit(true); 244 status_char = nullptr; 245 prov_server = nullptr; 246 247 Serial.printf("[prov] BLE freed, heap: %u bytes free\n", ESP.getFreeHeap()); 248 } 249 250 #else 251 252 void boot::provisioning::start(void) {} 253 254 #endif 255 256 // ───────────────────────────────────────────────────────────────────────────── 257 // Tests 258 // ───────────────────────────────────────────────────────────────────────────── 259 #ifdef PIO_UNIT_TESTING 260 261 #include "provisioning.h" 262 #include <testing/utils.h> 263 264 #include <config.h> 265 266 #include <Preferences.h> 267 #include <WiFi.h> 268 #include <esp_wifi.h> 269 270 static bool test_open_namespace(const char *name_space, bool readonly, 271 Preferences *prefs) { 272 return prefs && prefs->begin(name_space, readonly); 273 } 274 275 static void test_provisioning_detects_credentials(void) { 276 GIVEN("build flags with WiFi credentials"); 277 THEN("provisioning state is detected"); 278 279 #if defined(CONFIG_WIFI_SSID) && defined(CONFIG_WIFI_PASS) 280 if (strlen(CONFIG_WIFI_SSID) > 0) { 281 TEST_ASSERT_TRUE_MESSAGE( 282 boot::provisioning::isProvisioned(), 283 "device: should be provisioned when build flags have SSID"); 284 return; 285 } 286 287 #endif 288 } 289 290 static void test_provisioning_config_roundtrip(void) { 291 GIVEN("custom provisioning values written to NVS"); 292 WHEN("they are read back"); 293 294 char username[64] = {0}; 295 char api_key[64] = {0}; 296 char device_name[64] = {0}; 297 298 Preferences prefs; 299 TEST_ASSERT_TRUE_MESSAGE( 300 test_open_namespace(config::provisioning::NVS_NAMESPACE, false, &prefs), 301 "device: provisioning NVS namespace must be writable"); 302 303 prefs.putString("username", "alice"); 304 prefs.putString("api_key", "secret-key"); 305 prefs.putString("device_name", "ceratina-lab"); 306 prefs.end(); 307 308 IdentityStringQuery username_query = { 309 .buffer = username, 310 .capacity = sizeof(username), 311 .ok = false, 312 }; 313 TEST_ASSERT_TRUE_MESSAGE(services::identity::access_username(&username_query), 314 "device: username should be readable after write"); 315 IdentityStringQuery api_key_query = { 316 .buffer = api_key, 317 .capacity = sizeof(api_key), 318 .ok = false, 319 }; 320 TEST_ASSERT_TRUE_MESSAGE(services::identity::accessAPIKey(&api_key_query), 321 "device: api key should be readable after write"); 322 IdentityStringQuery device_name_query = { 323 .buffer = device_name, 324 .capacity = sizeof(device_name), 325 .ok = false, 326 }; 327 TEST_ASSERT_TRUE_MESSAGE( 328 services::identity::access_device_name(&device_name_query), 329 "device: device name should be readable after write"); 330 331 TEST_ASSERT_EQUAL_STRING_MESSAGE("alice", username, 332 "device: username mismatch after roundtrip"); 333 TEST_ASSERT_EQUAL_STRING_MESSAGE("secret-key", api_key, 334 "device: api key mismatch after roundtrip"); 335 TEST_ASSERT_EQUAL_STRING_MESSAGE( 336 "ceratina-lab", device_name, 337 "device: device name mismatch after roundtrip"); 338 } 339 340 static void test_provisioning_reset_clears_all(void) { 341 GIVEN("provisioning and WiFi NVS entries"); 342 WHEN("reset is called"); 343 344 WifiNvsSnapshot wifi_snapshot = {}; 345 wifi_nvs_save(&wifi_snapshot); 346 347 { 348 Preferences prov_prefs; 349 TEST_ASSERT_TRUE_MESSAGE( 350 test_open_namespace(config::provisioning::NVS_NAMESPACE, false, 351 &prov_prefs), 352 "device: provisioning NVS namespace must be writable"); 353 prov_prefs.putString("username", "bob"); 354 prov_prefs.putString("api_key", "temp-key"); 355 prov_prefs.putString("device_name", "temporary-device"); 356 prov_prefs.end(); 357 } 358 359 { 360 Preferences wifi_prefs; 361 TEST_ASSERT_TRUE_MESSAGE( 362 test_open_namespace(config::wifi::NVS_NAMESPACE, false, &wifi_prefs), 363 "device: wifi NVS namespace must be writable"); 364 wifi_prefs.putString("ap_ssid", "test-ap"); 365 wifi_prefs.putString("ap_pass", "test-pass"); 366 wifi_prefs.putBool("ap_on", true); 367 wifi_prefs.end(); 368 } 369 370 boot::provisioning::reset(); 371 372 { 373 Preferences prov_prefs; 374 TEST_ASSERT_TRUE_MESSAGE( 375 test_open_namespace(config::provisioning::NVS_NAMESPACE, true, 376 &prov_prefs), 377 "device: provisioning NVS namespace must remain readable"); 378 TEST_ASSERT_FALSE_MESSAGE(prov_prefs.isKey("username"), 379 "device: username should be cleared by reset"); 380 TEST_ASSERT_FALSE_MESSAGE(prov_prefs.isKey("api_key"), 381 "device: api_key should be cleared by reset"); 382 TEST_ASSERT_FALSE_MESSAGE(prov_prefs.isKey("device_name"), 383 "device: device_name should be cleared by reset"); 384 prov_prefs.end(); 385 } 386 387 { 388 Preferences wifi_prefs; 389 TEST_ASSERT_TRUE_MESSAGE( 390 test_open_namespace(config::wifi::NVS_NAMESPACE, true, &wifi_prefs), 391 "device: wifi NVS namespace must remain readable"); 392 TEST_ASSERT_FALSE_MESSAGE(wifi_prefs.isKey("ap_ssid"), 393 "device: AP SSID should be cleared by reset"); 394 TEST_ASSERT_FALSE_MESSAGE(wifi_prefs.isKey("ap_pass"), 395 "device: AP password should be cleared by reset"); 396 TEST_ASSERT_FALSE_MESSAGE( 397 wifi_prefs.isKey("ap_on"), 398 "device: AP enabled flag should be cleared by reset"); 399 wifi_prefs.end(); 400 } 401 402 wifi_nvs_restore(&wifi_snapshot); 403 } 404 405 static void test_provisioning_empty_returns_false(void) { 406 GIVEN("a cleared NVS namespace"); 407 WHEN("identity fields are read"); 408 409 { 410 Preferences prefs; 411 TEST_ASSERT_TRUE_MESSAGE( 412 test_open_namespace(config::provisioning::NVS_NAMESPACE, false, &prefs), 413 "device: provisioning NVS namespace must be writable"); 414 prefs.clear(); 415 prefs.end(); 416 } 417 418 char value[64] = {0}; 419 IdentityStringQuery query = { 420 .buffer = value, 421 .capacity = sizeof(value), 422 .ok = false, 423 }; 424 TEST_ASSERT_FALSE_MESSAGE(services::identity::access_username(&query), 425 "device: missing username should return false"); 426 TEST_ASSERT_FALSE_MESSAGE(services::identity::accessAPIKey(&query), 427 "device: missing api key should return false"); 428 TEST_ASSERT_FALSE_MESSAGE(services::identity::access_device_name(&query), 429 "device: missing device name should return false"); 430 } 431 432 static void test_provisioning_uuids_configured(void) { 433 GIVEN("BLE provisioning constants"); 434 THEN("UUIDs are configured and non-empty"); 435 436 TEST_ASSERT_NOT_NULL_MESSAGE( 437 config::provisioning::SERVICE_UUID, 438 "device: provisioning service UUID must be configured"); 439 TEST_ASSERT_NOT_NULL_MESSAGE( 440 config::provisioning::CONFIG_UUID, 441 "device: provisioning config UUID must be configured"); 442 TEST_ASSERT_NOT_EMPTY_MESSAGE(config::provisioning::SERVICE_UUID, 443 "device: provisioning service UUID must not be empty"); 444 TEST_ASSERT_NOT_EMPTY_MESSAGE(config::provisioning::CONFIG_UUID, 445 "device: provisioning config UUID must not be empty"); 446 } 447 448 void boot::provisioning::test(void) { 449 MODULE("Provisioning"); 450 RUN_TEST(test_provisioning_detects_credentials); 451 RUN_TEST(test_provisioning_config_roundtrip); 452 RUN_TEST(test_provisioning_reset_clears_all); 453 RUN_TEST(test_provisioning_empty_returns_false); 454 RUN_TEST(test_provisioning_uuids_configured); 455 } 456 457 #endif