/ firmware / src / boot / provisioning.cpp
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