This shows you the differences between two versions of the page.
|
iothings:laboratoare:2025:lab2 [2025/10/06 13:31] dan.tudose |
iothings:laboratoare:2025:lab2 [2025/10/07 13:44] (current) dan.tudose [BLE Security and Pairing] |
||
|---|---|---|---|
| Line 484: | Line 484: | ||
| The Bluetooth Low Energy (BLE) Current Time Service (CTS) is a standardized GATT (Generic Attribute Profile) service defined by the Bluetooth SIG. It allows a BLE peripheral (like a smartwatch or a clock) to expose the current date and time to another device (like a smartphone or computer). | The Bluetooth Low Energy (BLE) Current Time Service (CTS) is a standardized GATT (Generic Attribute Profile) service defined by the Bluetooth SIG. It allows a BLE peripheral (like a smartwatch or a clock) to expose the current date and time to another device (like a smartphone or computer). | ||
| - | |||
| - | Here’s a breakdown of what it is and how it works: | ||
| ==== Overview ==== | ==== Overview ==== | ||
| Line 501: | Line 499: | ||
| [[https://www.bluetooth.com/specifications/specs/cts-1-1/| here]] | [[https://www.bluetooth.com/specifications/specs/cts-1-1/| here]] | ||
| + | ^ Field | Size (bytes) | Description | Range ^ | ||
| + | | Year | 2 | Year (e.g. 2025) | 1582–9999 | | ||
| + | | Month | 1 | Month | 1–12 | | ||
| + | | Day | 1 | Day of month | 1–31 | | ||
| + | | Hours | 1 | Hours | 0–23 | | ||
| + | | Minutes | 1 | Minutes | 0–59 | | ||
| + | | Seconds | 1 | Seconds | 0–59 | | ||
| + | | Day of Week | 1 | Monday = 1 … Sunday = 7 | 1–7 | | ||
| + | | Fractions256 | 1 | 1/256th of a second (0–255) | — | | ||
| + | | Adjust Reason | 1 | Why time was changed | Bitmask | | ||
| + | |||
| + | Adjust Reason bitmask: | ||
| + | |||
| + | ^ Bit | Meaning ^ | ||
| + | | 0 | Manual time update | | ||
| + | | 1 | External reference time update (e.g., via NTP or GNSS) | | ||
| + | | 2 | Change of time zone | | ||
| + | | 3 | Change of DST (Daylight Saving Time) | | ||
| + | |||
| + | ==== Operation Example ==== | ||
| + | |||
| + | * A client (e.g., BLE thermometer) connects to a server (e.g., smartphone). | ||
| + | * The client reads the Current Time characteristic to get the date/time. | ||
| + | * The client may subscribe to notifications, so when the time updates (e.g., due to DST), it receives an update automatically. | ||
| + | |||
| + | |||
| + | ==== Set Up nRF Connect to Broadcast Current Time Service (CTS) ==== | ||
| + | |||
| + | <note warning>These steps are for Android. nRF Connect on iOS can advertise as a peripheral, but emulating CTS server behavior there is limited compared to Android.</note> | ||
| + | |||
| + | |||
| + | === Part 1 — Create a CTS GATT Server (Android) === | ||
| + | |||
| + | 1. **Open nRF Connect → Menu ☰ → “Configure GATT server”.** | ||
| + | * Tap **Add service**. From the list, pick **Current Time Service (0x1805)** (there’s a built-in preset). | ||
| + | |||
| + | 2. **Verify characteristics.** | ||
| + | * Inside the service, you should see: | ||
| + | * **Current Time (0x2A2B)** — properties typically **Read**, **Notify**, and **Write (with response)**. | ||
| + | * (Optional) **Local Time Information (0x2A0F)** and **Reference Time Information (0x2A14)**. | ||
| + | * If something’s missing, add it manually with the correct 16-bit UUIDs. | ||
| + | |||
| + | 3. **(Optional) Set an initial value manually** for testing: | ||
| + | The Current Time value is 10 bytes. Example for **2025-10-06 14:32:10**, **Monday**, fractions=0, **AdjustReason=External ref update**: | ||
| + | `E9 07 0A 06 0E 20 0A 01 00 02` | ||
| + | (Little-endian year 0x07E9, then month, day, hour, minute, second, day-of-week 1=Mon, fractions256, adjust-reason bitmask). | ||
| + | |||
| + | |||
| + | === Part 2 — Advertise it so Clients Can Find You === | ||
| + | |||
| + | 1. **Go to “Advertiser”** (tab at the bottom) and **create a new set**: | ||
| + | * Enable **Connectable** (so the client can connect). | ||
| + | * Add **Service UUID 0x1805** to the advertising data (helps clients filter). | ||
| + | * Start advertising. | ||
| + | |||
| + | 2. **Start the server** (if your app version separates server start from advertising). | ||
| + | * Make sure your created CTS config is the active one. | ||
| + | |||
| + | |||
| + | === Part 3 — Connect from the Client and Sync Time === | ||
| + | |||
| + | 1. **Connect from your BLE client**. | ||
| + | * Many CTS clients will **read 0x2A2B** on connect and may **subscribe to notifications**. | ||
| + | * Some stacks require **bonding** before allowing CTS writes/reads; accept pairing if prompted. | ||
| + | |||
| + | 2. **Confirm it works.** | ||
| + | * On the client, you should see **Service 0x1805** with **Characteristic 0x2A2B**. | ||
| + | * A read should return your current time payload; enabling notifications will push updates when you modify it. | ||
| + | |||
| + | ==== CTS Service on NimBLE ==== | ||
| + | |||
| + | Here is an example of a NimBLE client that discovers a peripheral advertising the Current Time Service, connects, reads characteristic 0x2A2B, and prints the decoded timestamp. Run it on your Sparrow node after setting up a CTS GATT Server. | ||
| + | |||
| + | <code C main.cpp> | ||
| + | #include <Arduino.h> | ||
| + | #include <NimBLEDevice.h> | ||
| + | #include <Wire.h> | ||
| + | #include <Adafruit_SSD1306.h> | ||
| + | |||
| + | namespace { | ||
| + | |||
| + | const NimBLEUUID CTS_SERVICE_UUID((uint16_t)0x1805); | ||
| + | const NimBLEUUID CTS_CHARACTERISTIC_UUID((uint16_t)0x2A2B); | ||
| + | constexpr uint32_t CTS_READ_INTERVAL_MS = 10000; // 10 seconds | ||
| + | |||
| + | const NimBLEAdvertisedDevice* g_targetDevice = nullptr; | ||
| + | NimBLEClient* g_client = nullptr; | ||
| + | NimBLERemoteCharacteristic* g_ctsChar = nullptr; | ||
| + | bool g_doConnect = false; | ||
| + | uint32_t g_lastReadMillis = 0; | ||
| + | |||
| + | constexpr uint8_t OLED_WIDTH = 128; | ||
| + | constexpr uint8_t OLED_HEIGHT = 64; | ||
| + | constexpr uint8_t OLED_RESET_PIN = -1; | ||
| + | constexpr uint8_t OLED_I2C_ADDR = 0x3C; | ||
| + | constexpr uint8_t OLED_LINE_HEIGHT = 10; | ||
| + | constexpr uint8_t OLED_TOP_MARGIN = 12; | ||
| + | |||
| + | Adafruit_SSD1306 g_display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RESET_PIN); | ||
| + | |||
| + | struct CurrentTime { | ||
| + | uint16_t year; | ||
| + | uint8_t month; | ||
| + | uint8_t day; | ||
| + | uint8_t hours; | ||
| + | uint8_t minutes; | ||
| + | uint8_t seconds; | ||
| + | uint8_t dayOfWeek; | ||
| + | uint8_t fractions256; | ||
| + | uint8_t adjustReason; | ||
| + | }; | ||
| + | |||
| + | void printCurrentTime(const CurrentTime& time) { | ||
| + | static const char* kDays[] = {"Unknown", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; | ||
| + | const char* dow = (time.dayOfWeek >= 1 && time.dayOfWeek <= 7) ? kDays[time.dayOfWeek] : kDays[0]; | ||
| + | |||
| + | Serial.printf("[CTS] %04u-%02u-%02u %02u:%02u:%02u (%s) adj=0x%02X frac=%u\n", | ||
| + | time.year, | ||
| + | time.month, | ||
| + | time.day, | ||
| + | time.hours, | ||
| + | time.minutes, | ||
| + | time.seconds, | ||
| + | dow, | ||
| + | time.adjustReason, | ||
| + | time.fractions256); | ||
| + | } | ||
| + | |||
| + | bool parseCurrentTime(const uint8_t* buffer, size_t length, CurrentTime& out) { | ||
| + | if (length < 10) { | ||
| + | Serial.printf("[CTS] Expected 10 bytes, got %u\n", static_cast<unsigned>(length)); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | out.year = static_cast<uint16_t>(buffer[0] | (buffer[1] << 8)); | ||
| + | out.month = buffer[2]; | ||
| + | out.day = buffer[3]; | ||
| + | out.hours = buffer[4]; | ||
| + | out.minutes = buffer[5]; | ||
| + | out.seconds = buffer[6]; | ||
| + | out.dayOfWeek = buffer[7]; | ||
| + | out.fractions256 = buffer[8]; | ||
| + | out.adjustReason = buffer[9]; | ||
| + | return true; | ||
| + | } | ||
| + | |||
| + | void displayStatusMessage(const char* message) { | ||
| + | g_display.clearDisplay(); | ||
| + | g_display.setCursor(0, OLED_TOP_MARGIN); | ||
| + | g_display.println(message); | ||
| + | g_display.display(); | ||
| + | } | ||
| + | |||
| + | void displayCurrentTime(const CurrentTime& time) { | ||
| + | g_display.clearDisplay(); | ||
| + | g_display.setCursor(0, OLED_TOP_MARGIN); | ||
| + | |||
| + | char line[24]; | ||
| + | snprintf(line, sizeof(line), "%04u-%02u-%02u", time.year, time.month, time.day); | ||
| + | g_display.println(line); | ||
| + | |||
| + | snprintf(line, sizeof(line), "%02u:%02u:%02u", time.hours, time.minutes, time.seconds); | ||
| + | g_display.println(line); | ||
| + | |||
| + | static const char* kDays[] = {"", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; | ||
| + | const char* dow = (time.dayOfWeek >= 1 && time.dayOfWeek <= 7) ? kDays[time.dayOfWeek] : ""; | ||
| + | g_display.println(dow); | ||
| + | |||
| + | g_display.display(); | ||
| + | } | ||
| + | |||
| + | class ClientCallbacks : public NimBLEClientCallbacks { | ||
| + | void onConnect(NimBLEClient* client) override { | ||
| + | Serial.printf("[BLE] Connected to %s\n", client->getPeerAddress().toString().c_str()); | ||
| + | displayStatusMessage("Connected"); | ||
| + | } | ||
| + | |||
| + | void onDisconnect(NimBLEClient* client, int reason) override { | ||
| + | Serial.printf("[BLE] Disconnected (reason %d), restarting scan\n", reason); | ||
| + | g_ctsChar = nullptr; | ||
| + | g_lastReadMillis = 0; | ||
| + | NimBLEDevice::deleteClient(client); | ||
| + | g_client = nullptr; | ||
| + | displayStatusMessage("Disconnected"); | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | } | ||
| + | }; | ||
| + | |||
| + | class ScanCallbacks : public NimBLEScanCallbacks { | ||
| + | void onResult(const NimBLEAdvertisedDevice* advertisedDevice) override { | ||
| + | if (!advertisedDevice->isAdvertisingService(CTS_SERVICE_UUID)) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | Serial.printf("[BLE] Found CTS peripheral: %s\n", advertisedDevice->toString().c_str()); | ||
| + | NimBLEDevice::getScan()->stop(); | ||
| + | |||
| + | g_targetDevice = advertisedDevice; | ||
| + | g_doConnect = true; | ||
| + | } | ||
| + | |||
| + | void onScanEnd(const NimBLEScanResults& results, int reason) override { | ||
| + | Serial.printf("[BLE] Scan ended (reason %d, %d devices)\n", reason, results.getCount()); | ||
| + | if (!g_doConnect && g_ctsChar == nullptr) { | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | } | ||
| + | } | ||
| + | }; | ||
| + | |||
| + | ClientCallbacks g_clientCallbacks; | ||
| + | ScanCallbacks g_scanCallbacks; | ||
| + | |||
| + | void startScan() { | ||
| + | NimBLEScan* scan = NimBLEDevice::getScan(); | ||
| + | scan->setScanCallbacks(&g_scanCallbacks, false); | ||
| + | scan->setActiveScan(true); | ||
| + | scan->setInterval(160); | ||
| + | scan->setWindow(120); | ||
| + | scan->start(0, false, true); | ||
| + | Serial.println("[BLE] Scanning for Current Time Service peripherals..."); | ||
| + | displayStatusMessage("Scanning..."); | ||
| + | } | ||
| + | |||
| + | bool connectToCurrentTimeService() { | ||
| + | if (!g_doConnect || g_targetDevice == nullptr) { | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | g_doConnect = false; | ||
| + | |||
| + | if (g_client == nullptr) { | ||
| + | g_client = NimBLEDevice::createClient(); | ||
| + | g_client->setClientCallbacks(&g_clientCallbacks, false); | ||
| + | g_client->setConnectionParams(12, 12, 0, 200); | ||
| + | g_client->setConnectTimeout(5000); | ||
| + | } | ||
| + | |||
| + | Serial.println("[BLE] Connecting to peripheral..."); | ||
| + | if (!g_client->connect(g_targetDevice)) { | ||
| + | Serial.println("[BLE] Connection failed, restarting scan"); | ||
| + | NimBLEDevice::deleteClient(g_client); | ||
| + | g_client = nullptr; | ||
| + | g_ctsChar = nullptr; | ||
| + | g_targetDevice = nullptr; | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | NimBLERemoteService* service = g_client->getService(CTS_SERVICE_UUID); | ||
| + | if (service == nullptr) { | ||
| + | Serial.println("[BLE] Current Time Service not found on peripheral"); | ||
| + | g_client->disconnect(); | ||
| + | NimBLEDevice::deleteClient(g_client); | ||
| + | g_client = nullptr; | ||
| + | g_ctsChar = nullptr; | ||
| + | g_targetDevice = nullptr; | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | NimBLERemoteCharacteristic* characteristic = service->getCharacteristic(CTS_CHARACTERISTIC_UUID); | ||
| + | if (characteristic == nullptr) { | ||
| + | Serial.println("[BLE] Current Time characteristic missing"); | ||
| + | g_client->disconnect(); | ||
| + | NimBLEDevice::deleteClient(g_client); | ||
| + | g_client = nullptr; | ||
| + | g_ctsChar = nullptr; | ||
| + | g_targetDevice = nullptr; | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | if (!characteristic->canRead()) { | ||
| + | Serial.println("[BLE] Current Time characteristic is not readable"); | ||
| + | g_client->disconnect(); | ||
| + | NimBLEDevice::deleteClient(g_client); | ||
| + | g_client = nullptr; | ||
| + | g_ctsChar = nullptr; | ||
| + | g_targetDevice = nullptr; | ||
| + | NimBLEDevice::getScan()->start(0, false, true); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | g_ctsChar = characteristic; | ||
| + | g_lastReadMillis = 0; | ||
| + | g_targetDevice = nullptr; | ||
| + | |||
| + | Serial.println("[BLE] Ready to read current time"); | ||
| + | displayStatusMessage("CTS ready"); | ||
| + | return true; | ||
| + | } | ||
| + | |||
| + | void readAndPrintCurrentTime() { | ||
| + | if (g_ctsChar == nullptr || g_client == nullptr) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | if (!g_client->isConnected()) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | const uint32_t now = millis(); | ||
| + | if (now - g_lastReadMillis < CTS_READ_INTERVAL_MS) { | ||
| + | return; | ||
| + | } | ||
| + | g_lastReadMillis = now; | ||
| + | |||
| + | Serial.println("[CTS] Reading current time..."); | ||
| + | NimBLEAttValue value = g_ctsChar->readValue(); | ||
| + | if (value.length() == 0) { | ||
| + | Serial.println("[CTS] Read returned empty value"); | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | CurrentTime time{}; | ||
| + | if (!parseCurrentTime(value.data(), value.length(), time)) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | printCurrentTime(time); | ||
| + | displayCurrentTime(time); | ||
| + | } | ||
| + | |||
| + | } // namespace | ||
| + | |||
| + | void setup() { | ||
| + | Serial.begin(115200); | ||
| + | while (!Serial) { | ||
| + | delay(10); | ||
| + | } | ||
| + | |||
| + | Serial.println(); | ||
| + | Serial.println("[BOOT] BLE Current Time Service client"); | ||
| + | |||
| + | NimBLEDevice::init("ESP32C6-CTS-Client"); | ||
| + | NimBLEDevice::setPower(ESP_PWR_LVL_P9); | ||
| + | NimBLEDevice::setSecurityAuth(false, false, true); | ||
| + | |||
| + | Wire.begin(21, 22); | ||
| + | if (!g_display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR)) { | ||
| + | Serial.println("[OLED] SSD1306 allocation failed"); | ||
| + | while (true) { | ||
| + | delay(1000); | ||
| + | } | ||
| + | } | ||
| + | g_display.clearDisplay(); | ||
| + | g_display.setTextColor(SSD1306_WHITE); | ||
| + | g_display.setTextSize(2); | ||
| + | g_display.setCursor(0, OLED_TOP_MARGIN); | ||
| + | g_display.println("BLE CTS Client"); | ||
| + | g_display.display(); | ||
| + | |||
| + | startScan(); | ||
| + | } | ||
| + | |||
| + | void loop() { | ||
| + | connectToCurrentTimeService(); | ||
| + | readAndPrintCurrentTime(); | ||
| + | delay(100); | ||
| + | } | ||
| + | |||
| + | </code> | ||
| + | |||
| + | ==== BLE Security and Pairing ==== | ||
| + | All BLE connections done so far in this lab have been without any authentication/encryption. For applications where this is necessary, BLE offers multiple security modes and a bonding scheme which establishes and encrypted connection between devices. | ||
| + | |||
| + | ^ Mode | Level | Description ^ | ||
| + | | **Mode 1, Level 1** | No security | No encryption or authentication | | ||
| + | | **Mode 1, Level 2** | Unauthenticated pairing with encryption | "Just Works" — no MITM protection | | ||
| + | | **Mode 1, Level 3** | Authenticated pairing with encryption | Uses Passkey/Numeric Comparison | | ||
| + | | **Mode 1, Level 4** | Authenticated LE Secure Connections | Uses modern ECDH key exchange | | ||
| + | |||
| + | |||
| + | A typical pairing scenarion over BLE goes through these steps: | ||
| + | |||
| + | 1. **Pairing Request/Response:** Devices agree on IO capabilities (keyboard, display, none). | ||
| + | 2. **Key Exchange:** Exchange temporary keys (STK, LTK, IRK, etc.). | ||
| + | 3. **Encryption Setup:** Link is encrypted using generated keys. | ||
| + | 4. **Bonding (optional):** Keys are stored persistently for future use. | ||
| + | |||
| + | We can test pairing with the nRF Connect mobile app. | ||
| + | |||
| + | - Flash your ESP32-C6 firmware with the code below, which has security-enabled GATT characteristics. | ||
| + | - Open **nRF Connect → Scan → Connect** to your ESP32-C6. | ||
| + | - Try to **read** a protected characteristic — expect something like: <code>Error 0x05: Insufficient Authentication</code> | ||
| + | - Tap **⋮ → Bond** to start pairing. | ||
| + | - Depending on setup: | ||
| + | * **Just Works:** automatic pairing. | ||
| + | * **Passkey Entry:** app prompts for a 6-digit PIN. | ||
| + | * **Numeric Comparison:** both sides show a code for confirmation. | ||
| + | - After pairing, retry read — it should succeed. | ||
| + | |||
| + | <code C main.cpp> | ||
| + | #include <Arduino.h> | ||
| + | #include <Wire.h> | ||
| + | #include <cmath> | ||
| + | #include <Adafruit_BME680.h> | ||
| + | #include <NimBLEDevice.h> | ||
| + | #include <NimBLEAdvertisementData.h> | ||
| + | |||
| + | |||
| + | |||
| + | namespace { | ||
| + | |||
| + | #define I2C_SDA_PIN 21 | ||
| + | #define I2C_SCL_PIN 22 | ||
| + | #define BME680_I2C_ADDR 0x77 | ||
| + | |||
| + | constexpr char kDeviceName[] = "ESP32C6 ESS"; | ||
| + | const NimBLEUUID kEnvironmentalServiceUUID((uint16_t)0x181A); | ||
| + | const NimBLEUUID kTemperatureCharUUID((uint16_t)0x2A6E); | ||
| + | const NimBLEUUID kHumidityCharUUID((uint16_t)0x2A6F); | ||
| + | const NimBLEUUID kPressureCharUUID((uint16_t)0x2A6D); | ||
| + | constexpr uint32_t kSecurityPasskey = 654321; // 6-digit static passkey for pairing | ||
| + | constexpr uint32_t kNotifyIntervalMs = 5000; // send notify every 5 seconds | ||
| + | |||
| + | Adafruit_BME680 g_bme; | ||
| + | bool g_bmeReady = false; | ||
| + | NimBLECharacteristic* g_temperatureCharacteristic = nullptr; | ||
| + | NimBLECharacteristic* g_humidityCharacteristic = nullptr; | ||
| + | NimBLECharacteristic* g_pressureCharacteristic = nullptr; | ||
| + | volatile bool g_isConnected = false; | ||
| + | uint32_t g_lastNotify = 0; | ||
| + | |||
| + | class ServerCallbacks : public NimBLEServerCallbacks { | ||
| + | void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override { | ||
| + | g_isConnected = true; | ||
| + | Serial.printf("[BLE] Central connected: %s\n", connInfo.getAddress().toString().c_str()); | ||
| + | } | ||
| + | |||
| + | void onDisconnect(NimBLEServer* server, NimBLEConnInfo& connInfo, int reason) override { | ||
| + | g_isConnected = false; | ||
| + | Serial.printf("[BLE] Central disconnected (reason %d)\n", reason); | ||
| + | if (!NimBLEDevice::startAdvertising()) { | ||
| + | Serial.println("[BLE] Failed to restart advertising"); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | uint32_t onPassKeyDisplay() override { | ||
| + | Serial.printf("[BLE] Displaying passkey: %06u\n", kSecurityPasskey); | ||
| + | return kSecurityPasskey; | ||
| + | } | ||
| + | |||
| + | void onConfirmPassKey(NimBLEConnInfo& connInfo, uint32_t pin) override { | ||
| + | Serial.printf("[BLE] Confirm passkey %06u for %s\n", pin, connInfo.getAddress().toString().c_str()); | ||
| + | NimBLEDevice::injectConfirmPasskey(connInfo, true); | ||
| + | } | ||
| + | |||
| + | void onAuthenticationComplete(NimBLEConnInfo& connInfo) override { | ||
| + | if (connInfo.isEncrypted()) { | ||
| + | Serial.printf("[BLE] Link encrypted; bonded=%s, authenticated=%s\n", | ||
| + | connInfo.isBonded() ? "yes" : "no", | ||
| + | connInfo.isAuthenticated() ? "yes" : "no"); | ||
| + | } else { | ||
| + | Serial.printf("[BLE] Authentication failed; disconnecting %s\n", connInfo.getAddress().toString().c_str()); | ||
| + | NimBLEDevice::getServer()->disconnect(connInfo.getConnHandle()); | ||
| + | } | ||
| + | } | ||
| + | }; | ||
| + | |||
| + | class ValueCallbacks : public NimBLECharacteristicCallbacks { | ||
| + | void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { | ||
| + | Serial.printf("[GATT] Read from %s on characteristic %s\n", | ||
| + | connInfo.getAddress().toString().c_str(), | ||
| + | characteristic->getUUID().toString().c_str()); | ||
| + | } | ||
| + | |||
| + | void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { | ||
| + | std::string value = characteristic->getValue(); | ||
| + | Serial.printf("[GATT] Write to %s from %s, %zu bytes\n", | ||
| + | characteristic->getUUID().toString().c_str(), | ||
| + | connInfo.getAddress().toString().c_str(), | ||
| + | value.size()); | ||
| + | } | ||
| + | }; | ||
| + | |||
| + | ServerCallbacks g_serverCallbacks; | ||
| + | ValueCallbacks g_valueCallbacks; | ||
| + | |||
| + | bool initBME680() { | ||
| + | if (!g_bme.begin(BME680_I2C_ADDR)) { | ||
| + | Serial.println("[BME680] Not found on 0x77, trying 0x76..."); | ||
| + | if (!g_bme.begin(0x76)) { | ||
| + | Serial.println("[BME680] Sensor not found. Check wiring/power."); | ||
| + | return false; | ||
| + | } | ||
| + | } | ||
| + | |||
| + | g_bme.setTemperatureOversampling(BME680_OS_8X); | ||
| + | g_bme.setHumidityOversampling(BME680_OS_2X); | ||
| + | g_bme.setPressureOversampling(BME680_OS_4X); | ||
| + | g_bme.setIIRFilterSize(BME680_FILTER_SIZE_3); | ||
| + | g_bme.setGasHeater(0, 0); // Disable gas heater for periodic environmental sampling | ||
| + | |||
| + | Serial.println("[BME680] Sensor initialized"); | ||
| + | return true; | ||
| + | } | ||
| + | |||
| + | bool readBME680(float& temperatureC, float& humidityPct, float& pressurePa) { | ||
| + | if (!g_bme.performReading()) { | ||
| + | Serial.println("[BME680] Failed to perform reading"); | ||
| + | return false; | ||
| + | } | ||
| + | |||
| + | temperatureC = g_bme.temperature; | ||
| + | humidityPct = g_bme.humidity; | ||
| + | pressurePa = g_bme.pressure; // Library returns Pascals | ||
| + | return true; | ||
| + | } | ||
| + | |||
| + | void setupSecurity() { | ||
| + | NimBLEDevice::setSecurityAuth(true, true, true); // bonding, MITM, secure connections | ||
| + | NimBLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_YESNO); | ||
| + | NimBLEDevice::setSecurityPasskey(kSecurityPasskey); | ||
| + | NimBLEDevice::setPower(ESP_PWR_LVL_P9); | ||
| + | } | ||
| + | |||
| + | void setupGattServer() { | ||
| + | NimBLEServer* server = NimBLEDevice::createServer(); | ||
| + | server->setCallbacks(&g_serverCallbacks, false); | ||
| + | |||
| + | NimBLEService* environmentalService = server->createService(kEnvironmentalServiceUUID); | ||
| + | |||
| + | g_temperatureCharacteristic = environmentalService->createCharacteristic( | ||
| + | kTemperatureCharUUID, | ||
| + | NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_ENC | | ||
| + | NIMBLE_PROPERTY::READ_AUTHEN | | ||
| + | NIMBLE_PROPERTY::NOTIFY); | ||
| + | g_temperatureCharacteristic->setCallbacks(&g_valueCallbacks); | ||
| + | uint8_t tempPlaceholder[2] = {0xFF, 0x7F}; // NaN equivalent (0x7FFF) | ||
| + | g_temperatureCharacteristic->setValue(tempPlaceholder, sizeof(tempPlaceholder)); | ||
| + | |||
| + | g_humidityCharacteristic = environmentalService->createCharacteristic( | ||
| + | kHumidityCharUUID, | ||
| + | NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_ENC | | ||
| + | NIMBLE_PROPERTY::READ_AUTHEN | | ||
| + | NIMBLE_PROPERTY::NOTIFY); | ||
| + | g_humidityCharacteristic->setCallbacks(&g_valueCallbacks); | ||
| + | uint8_t humidityPlaceholder[2] = {0xFF, 0xFF}; | ||
| + | g_humidityCharacteristic->setValue(humidityPlaceholder, sizeof(humidityPlaceholder)); | ||
| + | |||
| + | g_pressureCharacteristic = environmentalService->createCharacteristic( | ||
| + | kPressureCharUUID, | ||
| + | NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_ENC | | ||
| + | NIMBLE_PROPERTY::READ_AUTHEN | | ||
| + | NIMBLE_PROPERTY::NOTIFY); | ||
| + | g_pressureCharacteristic->setCallbacks(&g_valueCallbacks); | ||
| + | uint8_t pressurePlaceholder[4] = {0xFF, 0xFF, 0xFF, 0xFF}; | ||
| + | g_pressureCharacteristic->setValue(pressurePlaceholder, sizeof(pressurePlaceholder)); | ||
| + | |||
| + | environmentalService->start(); | ||
| + | |||
| + | NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); | ||
| + | NimBLEAdvertisementData advData; | ||
| + | advData.setName(kDeviceName); | ||
| + | advData.addServiceUUID(kEnvironmentalServiceUUID); | ||
| + | advData.setAppearance(0x0341); // Thermometer | ||
| + | advertising->setAdvertisementData(advData); | ||
| + | |||
| + | NimBLEAdvertisementData scanData; | ||
| + | scanData.setName("Env Sensing", false); | ||
| + | advertising->setScanResponseData(scanData); | ||
| + | |||
| + | advertising->setConnectableMode(BLE_GAP_CONN_MODE_UND); | ||
| + | advertising->setDiscoverableMode(BLE_GAP_DISC_MODE_GEN); | ||
| + | |||
| + | if (advertising->start()) { | ||
| + | Serial.println("[BLE] Advertising environmental sensing service"); | ||
| + | } else { | ||
| + | Serial.println("[BLE] Failed to start advertising"); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | void updateEnvironmentalMeasurements() { | ||
| + | if (!g_isConnected || !g_bmeReady || | ||
| + | g_temperatureCharacteristic == nullptr || | ||
| + | g_humidityCharacteristic == nullptr || | ||
| + | g_pressureCharacteristic == nullptr) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | const uint32_t now = millis(); | ||
| + | if (now - g_lastNotify < kNotifyIntervalMs) { | ||
| + | return; | ||
| + | } | ||
| + | g_lastNotify = now; | ||
| + | |||
| + | float temperatureC = 0.0f; | ||
| + | float humidityPct = 0.0f; | ||
| + | float pressurePa = 0.0f; | ||
| + | if (!readBME680(temperatureC, humidityPct, pressurePa)) { | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | const int16_t temperatureHundredths = | ||
| + | static_cast<int16_t>(std::lround(temperatureC * 100.0)); | ||
| + | const uint16_t humidityHundredths = | ||
| + | static_cast<uint16_t>(std::lround(humidityPct * 100.0)); | ||
| + | const uint32_t pressureValue = static_cast<uint32_t>(std::lround(pressurePa)); | ||
| + | |||
| + | uint8_t temperaturePayload[2] = { | ||
| + | static_cast<uint8_t>(temperatureHundredths & 0xFF), | ||
| + | static_cast<uint8_t>((temperatureHundredths >> 8) & 0xFF), | ||
| + | }; | ||
| + | g_temperatureCharacteristic->setValue(temperaturePayload, sizeof(temperaturePayload)); | ||
| + | g_temperatureCharacteristic->notify(); | ||
| + | |||
| + | uint8_t humidityPayload[2] = { | ||
| + | static_cast<uint8_t>(humidityHundredths & 0xFF), | ||
| + | static_cast<uint8_t>((humidityHundredths >> 8) & 0xFF), | ||
| + | }; | ||
| + | g_humidityCharacteristic->setValue(humidityPayload, sizeof(humidityPayload)); | ||
| + | g_humidityCharacteristic->notify(); | ||
| + | |||
| + | uint8_t pressurePayload[4] = { | ||
| + | static_cast<uint8_t>(pressureValue & 0xFF), | ||
| + | static_cast<uint8_t>((pressureValue >> 8) & 0xFF), | ||
| + | static_cast<uint8_t>((pressureValue >> 16) & 0xFF), | ||
| + | static_cast<uint8_t>((pressureValue >> 24) & 0xFF), | ||
| + | }; | ||
| + | g_pressureCharacteristic->setValue(pressurePayload, sizeof(pressurePayload)); | ||
| + | g_pressureCharacteristic->notify(); | ||
| + | |||
| + | Serial.printf("[ESS] Notified T=%.2fC H=%.2f%% P=%.2fhPa\n", | ||
| + | temperatureC, | ||
| + | humidityPct, | ||
| + | pressurePa / 100.0f); | ||
| + | } | ||
| + | |||
| + | } // namespace | ||
| + | |||
| + | void setup() { | ||
| + | Serial.begin(115200); | ||
| + | while (!Serial) { | ||
| + | delay(10); | ||
| + | } | ||
| + | delay(1000); | ||
| + | Serial.println(); | ||
| + | Serial.println("[BOOT] ESP32-C6 Environmental Sensing Server"); | ||
| + | |||
| + | Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN); | ||
| + | g_bmeReady = initBME680(); | ||
| + | if (!g_bmeReady) { | ||
| + | Serial.println("[BOOT] BME680 not ready; characteristics will remain invalid"); | ||
| + | } | ||
| + | |||
| + | NimBLEDevice::init(kDeviceName); | ||
| + | setupSecurity(); | ||
| + | setupGattServer(); | ||
| + | } | ||
| + | |||
| + | void loop() { | ||
| + | updateEnvironmentalMeasurements(); | ||
| + | delay(100); | ||
| + | } | ||
| + | |||
| + | |||
| + | </code> | ||
| ===== Web BLE Application ===== | ===== Web BLE Application ===== | ||
| Line 509: | Line 1165: | ||
| Build the application in the tutorial and deploy the web page in your GitHub account. | Build the application in the tutorial and deploy the web page in your GitHub account. | ||
| - | === Assignment === | + | ===== Assignment ===== |
| <note> Modify the web page and the BLE app to display the BME680 sensor data (temperature, pressure and humidity). </note> | <note> Modify the web page and the BLE app to display the BME680 sensor data (temperature, pressure and humidity). </note> | ||