This shows you the differences between two versions of the page.
iothings:laboratoare:2025:lab1 [2025/09/25 17:53] dan.tudose [First Steps] |
iothings:laboratoare:2025:lab1 [2025/09/25 22:37] (current) dan.tudose |
||
---|---|---|---|
Line 24: | Line 24: | ||
After downloading and installing the PlatformIO extension, create a new project using any ESP32-C6 board. After project creation, you will need to edit the platformio.ini file and replace it with the following: | After downloading and installing the PlatformIO extension, create a new project using any ESP32-C6 board. After project creation, you will need to edit the platformio.ini file and replace it with the following: | ||
+ | |||
<code bash platformio.ini> | <code bash platformio.ini> | ||
; PlatformIO Project Configuration File | ; PlatformIO Project Configuration File | ||
Line 34: | Line 35: | ||
; Please visit documentation for the other options and examples | ; Please visit documentation for the other options and examples | ||
; https://docs.platformio.org/page/projectconf.html | ; https://docs.platformio.org/page/projectconf.html | ||
+ | |||
[env:esp32-c6-sparrow] | [env:esp32-c6-sparrow] | ||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip | platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip | ||
board = esp32-c6-devkitm-1 | board = esp32-c6-devkitm-1 | ||
framework = arduino | framework = arduino | ||
+ | ; use SPIFFS for on-board files | ||
+ | board_build.filesystem = spiffs | ||
+ | |||
build_flags = | build_flags = | ||
- | -D ARDUINO_USB_MODE=1 | + | -D ARDUINO_USB_MODE=1 |
- | -D ARDUINO_USB_CDC_ON_BOOT=1 | + | -D ARDUINO_USB_CDC_ON_BOOT=1 |
- | -D ESP32_C6_env | + | -D ESP32_C6_env |
- | ; Serial monitor options | ||
monitor_speed = 115200 | monitor_speed = 115200 | ||
lib_deps = | lib_deps = | ||
- | adafruit/Adafruit NeoPixel@^1.11.0 | + | adafruit/Adafruit NeoPixel@^1.11.0 |
- | adafruit/Adafruit GFX Library@^1.11.9 | + | adafruit/Adafruit GFX Library@^1.11.9 |
- | adafruit/Adafruit SSD1306@^2.5.10 | + | adafruit/Adafruit SSD1306@^2.5.10 |
- | dantudose/LTR308 library@^1.0 | + | adafruit/Adafruit BME680 Library |
- | https://github.com/sparkfun/SparkFun_MAX1704x_Fuel_Gauge_Arduino_Library | + | dantudose/LTR308 library@^1.0 |
- | stm32duino/STM32duino LSM6DSL@^2.0.0 | + | https://github.com/sparkfun/SparkFun_MAX1704x_Fuel_Gauge_Arduino_Library |
+ | h2zero/NimBLE-Arduino@^2.1.0 | ||
+ | |||
</code> | </code> | ||
Line 106: | Line 112: | ||
</code> | </code> | ||
+ | === 3. Bringing up the sensors === | ||
- | === 3. Scan and display local WiFi networks === | + | Now, let's do a quick sensor bring-up for the BME680 (temperature, humidity, pressure and gas). The sensor is connected on the I2C bus. Use the code below to read values over the serial terminal. |
- | Load this WiFi scanner code: | + | <code C main.cpp> |
+ | #include <Arduino.h> | ||
+ | #include <Wire.h> | ||
+ | #include <Adafruit_BME680.h> | ||
+ | |||
+ | // Change this if you want altitude computed for your location | ||
+ | #define SEALEVEL_HPA (1013.25f) | ||
+ | |||
+ | // Try both common I2C addresses | ||
+ | Adafruit_BME680 bme; // use the default constructor | ||
+ | |||
+ | bool beginBME680() { | ||
+ | // SDA on pin 21 and SCL on pin 22 | ||
+ | Wire.begin(21, 22); | ||
+ | |||
+ | // Try 0x76 first | ||
+ | if (bme.begin(0x76, &Wire)) { | ||
+ | Serial.println("[BME680] Found at 0x76"); | ||
+ | return true; | ||
+ | } | ||
+ | // Then 0x77 | ||
+ | if (bme.begin(0x77, &Wire)) { | ||
+ | Serial.println("[BME680] Found at 0x77"); | ||
+ | return true; | ||
+ | } | ||
+ | |||
+ | Serial.println("[BME680] Sensor not found at 0x76 or 0x77. Check wiring/power."); | ||
+ | return false; | ||
+ | } | ||
+ | |||
+ | void setupBME680() { | ||
+ | // Oversampling & filter settings tuned for ~1 Hz updates | ||
+ | bme.setTemperatureOversampling(BME680_OS_8X); | ||
+ | bme.setHumidityOversampling(BME680_OS_2X); | ||
+ | bme.setPressureOversampling(BME680_OS_4X); | ||
+ | bme.setIIRFilterSize(BME680_FILTER_SIZE_3); | ||
+ | |||
+ | // Enable gas heater: 320°C for 150 ms (typical example) | ||
+ | bme.setGasHeater(320, 150); | ||
+ | } | ||
+ | |||
+ | void setup() { | ||
+ | Serial.begin(115200); | ||
+ | while (!Serial) { delay(100); } | ||
+ | |||
+ | Serial.println("\n[BOOT] BME680 serial demo (1 Hz)"); | ||
+ | |||
+ | if (!beginBME680()) { | ||
+ | // Stay here so you can read the error | ||
+ | while (true) { delay(1000); } | ||
+ | } | ||
+ | |||
+ | setupBME680(); | ||
+ | } | ||
+ | |||
+ | void loop() { | ||
+ | // Trigger a reading and wait for completion | ||
+ | if (!bme.performReading()) { | ||
+ | Serial.println("[BME680] Failed to perform reading!"); | ||
+ | delay(1000); | ||
+ | return; | ||
+ | } | ||
+ | |||
+ | // Values from the Adafruit_BME680 library: | ||
+ | float temperatureC = bme.temperature; // °C | ||
+ | float pressureHpa = bme.pressure / 100.0f; // Pa -> hPa | ||
+ | float humidityPct = bme.humidity; // % | ||
+ | float gasOhms = bme.gas_resistance; // Ω | ||
+ | float altitudeM = bme.readAltitude(SEALEVEL_HPA);// meters (approx.) | ||
+ | |||
+ | // Print nicely | ||
+ | Serial.print("T: "); Serial.print(temperatureC, 2); Serial.print(" °C | "); | ||
+ | Serial.print("RH: "); Serial.print(humidityPct, 1); Serial.print(" % | "); | ||
+ | Serial.print("P: "); Serial.print(pressureHpa, 2); Serial.print(" hPa | "); | ||
+ | Serial.print("Gas: ");Serial.print(gasOhms, 0); Serial.print(" Ω | "); | ||
+ | Serial.print("Alt: ");Serial.print(altitudeM, 1); Serial.println(" m"); | ||
+ | |||
+ | // 1 Hz update | ||
+ | delay(1000); | ||
+ | } | ||
+ | |||
+ | </code> | ||
+ | |||
+ | === 4. Scan and display local WiFi networks === | ||
+ | |||
+ | Now let's make sure we can connect to WiFi. Load this WiFi scanner code: | ||
<code C main.cpp> | <code C main.cpp> | ||
Line 178: | Line 270: | ||
- | === 3. Advertise on BLE === | + | === 6. Web Server === |
- | Load the "SimpleBleDevice" example. Install on your phone an app that scans nearby Bluetooth devices, such as this [[https://play.google.com/store/apps/details?id=com.macdom.ble.blescanner&hl=en&gl=US|BLE Scanner]]. Check if your device is in the list. | + | Now let's bring everything together and configure the board to connect to our local WiFi, act as a web server and display a dynamic html page in which it can plot the sensor readings. |
+ | |||
+ | Use this code to build the firmware image: | ||
+ | |||
+ | <code C main.cpp> | ||
+ | #include <Arduino.h> | ||
+ | #include <Wire.h> | ||
+ | #include <WiFi.h> | ||
+ | #include <WebServer.h> | ||
+ | #include <SPIFFS.h> | ||
+ | #include <ESPmDNS.h> | ||
+ | #include <Adafruit_BME680.h> | ||
+ | |||
+ | #define SEALEVEL_HPA (1013.25f) // adjust for better altitude accuracy | ||
+ | |||
+ | // ===== WiFi ===== | ||
+ | const char* WIFI_SSID = "YOUR_SSID"; | ||
+ | const char* WIFI_PASS = "YOUR_PASSWORD"; | ||
+ | const char* MDNS_NAME = "sparrow"; // -> http://sparrow.local/ | ||
+ | |||
+ | // ===== Web ===== | ||
+ | WebServer server(80); | ||
+ | |||
+ | // ===== BME680 ===== | ||
+ | Adafruit_BME680 bme; | ||
+ | bool haveBME = false; | ||
+ | |||
+ | // ===== History buffer (5 minutes @ 1 Hz) ===== | ||
+ | static const size_t HISTORY_MAX = 300; | ||
+ | |||
+ | struct Sample { | ||
+ | uint32_t t_ms; | ||
+ | float tC; | ||
+ | float hPct; | ||
+ | float pHpa; | ||
+ | float altM; | ||
+ | }; | ||
+ | |||
+ | Sample hist[HISTORY_MAX]; | ||
+ | size_t histHead = 0; | ||
+ | size_t histCount = 0; | ||
+ | |||
+ | void pushSample(const Sample& s) { | ||
+ | hist[histHead] = s; | ||
+ | histHead = (histHead + 1) % HISTORY_MAX; | ||
+ | if (histCount < HISTORY_MAX) histCount++; | ||
+ | } | ||
+ | |||
+ | Sample getSampleByAge(size_t iOldToNew) { | ||
+ | size_t idx = (histHead + HISTORY_MAX - histCount + iOldToNew) % HISTORY_MAX; | ||
+ | return hist[idx]; | ||
+ | } | ||
+ | |||
+ | // ===== Sensor init and reading ===== | ||
+ | bool beginBME680() { | ||
+ | Wire.begin(21, 22); // set custom SDA/SCL here if needed: Wire.begin(SDA, SCL); | ||
+ | |||
+ | if (bme.begin(0x76, &Wire)) { Serial.println("[BME680] Found at 0x76"); return true; } | ||
+ | if (bme.begin(0x77, &Wire)) { Serial.println("[BME680] Found at 0x77"); return true; } | ||
+ | Serial.println("[BME680] Not found at 0x76/0x77"); | ||
+ | return false; | ||
+ | } | ||
+ | |||
+ | void setupBME680() { | ||
+ | bme.setTemperatureOversampling(BME680_OS_8X); | ||
+ | bme.setHumidityOversampling(BME680_OS_2X); | ||
+ | bme.setPressureOversampling(BME680_OS_4X); | ||
+ | bme.setIIRFilterSize(BME680_FILTER_SIZE_3); | ||
+ | bme.setGasHeater(320, 150); // optional | ||
+ | } | ||
+ | |||
+ | bool takeAndStoreSample() { | ||
+ | if (!haveBME) return false; | ||
+ | if (!bme.performReading()) return false; | ||
+ | |||
+ | Sample s; | ||
+ | s.t_ms = millis(); | ||
+ | s.tC = bme.temperature; | ||
+ | s.hPct = bme.humidity; | ||
+ | s.pHpa = bme.pressure / 100.0f; | ||
+ | s.altM = bme.readAltitude(SEALEVEL_HPA); | ||
+ | pushSample(s); | ||
+ | return true; | ||
+ | } | ||
+ | |||
+ | // ===== HTTP handlers ===== | ||
+ | void handleRoot() { | ||
+ | File f = SPIFFS.open("/index.html", "r"); | ||
+ | if (!f) { server.send(500, "text/plain", "index.html missing"); return; } | ||
+ | server.streamFile(f, "text/html"); | ||
+ | f.close(); | ||
+ | } | ||
+ | |||
+ | void handleApiSensor() { | ||
+ | if (histCount == 0) takeAndStoreSample(); | ||
+ | Sample s = (histCount == 0) ? Sample{millis(), NAN, NAN, NAN, NAN} | ||
+ | : getSampleByAge(histCount - 1); | ||
+ | |||
+ | String json = "{"; | ||
+ | json += "\"ok\":" + String(haveBME ? "true" : "false"); | ||
+ | json += ",\"temperature_c\":" + String(isnan(s.tC)?0:s.tC, 2); | ||
+ | json += ",\"humidity_pct\":" + String(isnan(s.hPct)?0:s.hPct, 1); | ||
+ | json += ",\"pressure_hpa\":" + String(isnan(s.pHpa)?0:s.pHpa, 2); | ||
+ | json += ",\"altitude_m\":" + String(isnan(s.altM)?0:s.altM, 1); | ||
+ | json += "}"; | ||
+ | server.send(200, "application/json", json); | ||
+ | } | ||
+ | |||
+ | void handleApiHistory() { | ||
+ | uint32_t nowms = millis(); | ||
+ | size_t n = histCount; | ||
+ | if (n == 0) { server.send(200, "application/json", "{\"ok\":true,\"n\":0}"); return; } | ||
+ | |||
+ | String json; | ||
+ | json.reserve(n * 64 + 128); | ||
+ | json += "{\"ok\":true,\"n\":" + String(n); | ||
+ | |||
+ | // seconds ago array | ||
+ | json += ",\"s\":["; | ||
+ | for (size_t i = 0; i < n; ++i) { | ||
+ | Sample s = getSampleByAge(i); | ||
+ | uint32_t secAgo = (nowms - s.t_ms) / 1000; | ||
+ | json += String(secAgo); | ||
+ | if (i + 1 < n) json += ","; | ||
+ | } | ||
+ | json += "]"; | ||
+ | |||
+ | auto appendSeries = [&](const char* key, float Sample::*field, int digits) { | ||
+ | json += ",\""; json += key; json += "\":["; | ||
+ | for (size_t i = 0; i < n; ++i) { | ||
+ | Sample s = getSampleByAge(i); | ||
+ | json += String(s.*field, digits); | ||
+ | if (i + 1 < n) json += ","; | ||
+ | } | ||
+ | json += "]"; | ||
+ | }; | ||
+ | |||
+ | appendSeries("tc", &Sample::tC, 2); | ||
+ | appendSeries("rh", &Sample::hPct, 1); | ||
+ | appendSeries("p", &Sample::pHpa, 2); | ||
+ | appendSeries("alt", &Sample::altM, 1); | ||
+ | |||
+ | json += "}"; | ||
+ | server.send(200, "application/json", json); | ||
+ | } | ||
+ | |||
+ | void handleApiStatus() { | ||
+ | bool up = WiFi.status() == WL_CONNECTED; | ||
+ | long rssi = up ? WiFi.RSSI() : -127; // dBm | ||
+ | uint32_t uptimeS = millis() / 1000; | ||
+ | |||
+ | String json = "{"; | ||
+ | json += "\"ok\":true"; | ||
+ | json += ",\"rssi_dbm\":" + String(rssi); | ||
+ | json += ",\"uptime_s\":" + String(uptimeS); | ||
+ | json += ",\"hostname\":\"" + String(MDNS_NAME) + "\""; | ||
+ | json += ",\"ip\":\"" + (up ? WiFi.localIP().toString() : String("")) + "\""; | ||
+ | json += "}"; | ||
+ | server.send(200, "application/json", json); | ||
+ | } | ||
+ | |||
+ | void setup() { | ||
+ | Serial.begin(115200); | ||
+ | while (!Serial) { delay(10); } | ||
+ | Serial.println("\n[BOOT] ESP32-C6 BME680 Web Graphs + Status + mDNS"); | ||
+ | |||
+ | if (!SPIFFS.begin(true)) Serial.println("[SPIFFS] Mount failed"); | ||
+ | else Serial.println("[SPIFFS] Mounted"); | ||
+ | |||
+ | // WiFi + mDNS | ||
+ | WiFi.mode(WIFI_STA); | ||
+ | WiFi.setSleep(false); | ||
+ | WiFi.setHostname(MDNS_NAME); | ||
+ | WiFi.begin(WIFI_SSID, WIFI_PASS); | ||
+ | Serial.printf("[WiFi] Connecting to %s", WIFI_SSID); | ||
+ | unsigned long t0 = millis(); | ||
+ | while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) { | ||
+ | Serial.print("."); | ||
+ | delay(500); | ||
+ | } | ||
+ | Serial.println(); | ||
+ | if (WiFi.status() == WL_CONNECTED) { | ||
+ | Serial.printf("[WiFi] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); | ||
+ | if (MDNS.begin(MDNS_NAME)) { | ||
+ | MDNS.addService("http", "tcp", 80); | ||
+ | Serial.printf("[mDNS] http://%s.local/\n", MDNS_NAME); | ||
+ | } else { | ||
+ | Serial.println("[mDNS] start failed"); | ||
+ | } | ||
+ | } else { | ||
+ | Serial.println("[WiFi] Failed to connect (continuing)"); | ||
+ | } | ||
+ | |||
+ | // Sensor | ||
+ | haveBME = beginBME680(); | ||
+ | if (haveBME) setupBME680(); | ||
+ | |||
+ | // Prime buffer | ||
+ | takeAndStoreSample(); | ||
+ | |||
+ | // Routes | ||
+ | server.on("/", HTTP_GET, handleRoot); | ||
+ | server.on("/api/sensor", HTTP_GET, handleApiSensor); | ||
+ | server.on("/api/history",HTTP_GET, handleApiHistory); | ||
+ | server.on("/api/status", HTTP_GET, handleApiStatus); | ||
+ | |||
+ | server.onNotFound([]() { | ||
+ | String path = server.uri(); | ||
+ | if (path == "/") { handleRoot(); return; } | ||
+ | if (!SPIFFS.exists(path)) { server.send(404, "text/plain", "Not found"); return; } | ||
+ | String ct = "text/plain"; | ||
+ | if (path.endsWith(".html")) ct = "text/html"; | ||
+ | else if (path.endsWith(".css")) ct = "text/css"; | ||
+ | else if (path.endsWith(".js")) ct = "application/javascript"; | ||
+ | File f = SPIFFS.open(path, "r"); server.streamFile(f, ct); f.close(); | ||
+ | }); | ||
+ | |||
+ | server.begin(); | ||
+ | Serial.println("[HTTP] Server started"); | ||
+ | if (WiFi.status() == WL_CONNECTED) | ||
+ | Serial.println("[HTTP] Open: http://" + WiFi.localIP().toString() + "/ or http://sparrow.local/"); | ||
+ | } | ||
+ | |||
+ | void loop() { | ||
+ | server.handleClient(); | ||
+ | |||
+ | // 1 Hz sampling | ||
+ | static uint32_t lastSample = 0; | ||
+ | uint32_t now = millis(); | ||
+ | if (now - lastSample >= 1000) { | ||
+ | lastSample = now; | ||
+ | takeAndStoreSample(); | ||
+ | } | ||
+ | delay(2); | ||
+ | } | ||
+ | |||
+ | |||
+ | </code> | ||
+ | |||
+ | The web page that the node will serve needs to be stored in ESP32's internal memory. It has a limited amount of internal Flash (8MB) which is enough to store a tiny web page. Create a new folder in the root of your project and mane it "data". In it, create a file called index.html and paste the contents below: | ||
+ | |||
+ | <code html index.html> | ||
+ | <!doctype html> | ||
+ | <html lang="en" data-theme="light"> | ||
+ | <head> | ||
+ | <meta charset="utf-8"/> | ||
+ | <title>BME680 Dashboard</title> | ||
+ | <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||
+ | <style> | ||
+ | :root { | ||
+ | --bg:#ffffff; --fg:#222; --muted:#777; --card:#f7f7f7; --axis:#00000066; --grid:#00000012; --line:#0b6; | ||
+ | } | ||
+ | [data-theme="dark"] { | ||
+ | --bg:#0f1115; --fg:#eaeef2; --muted:#9aa4b2; --card:#171a21; --axis:#ffffff66; --grid:#ffffff13; --line:#38bdf8; | ||
+ | } | ||
+ | html,body { height:100%; } | ||
+ | body { background: var(--bg); color: var(--fg); | ||
+ | font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; margin: 16px; } | ||
+ | h1 { margin: 0 0 8px 0; } | ||
+ | .row { display:flex; gap:16px; flex-wrap:wrap; align-items:baseline; } | ||
+ | .metric { font-size: 1.4rem; } | ||
+ | .label { color: var(--muted); font-size:.9rem; } | ||
+ | .grid { display:grid; grid-template-columns: repeat(2, minmax(280px,1fr)); gap:16px; } | ||
+ | .card { background: var(--card); border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.06); } | ||
+ | .title { color: var(--muted); margin-bottom: 6px; } | ||
+ | canvas { width:100%; height:200px; display:block; } | ||
+ | .ts { color: var(--muted); margin-top: 8px; font-size:.9rem; } | ||
+ | @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } } | ||
+ | |||
+ | /* Toggle + status pill */ | ||
+ | .topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:12px; } | ||
+ | .toggle { cursor:pointer; padding:6px 10px; border-radius:10px; background:var(--card); border:1px solid #00000010; } | ||
+ | .pill { position: sticky; top: 8px; align-self: start; padding:6px 10px; border-radius:999px; | ||
+ | background:var(--card); border:1px solid #00000010; color:var(--fg); font-size:.9rem; } | ||
+ | .pill a { color: inherit; text-decoration: none; border-bottom:1px dotted var(--muted); } | ||
+ | </style> | ||
+ | </head> | ||
+ | <body> | ||
+ | <div class="topbar"> | ||
+ | <h1>BME680 Dashboard (5 min)</h1> | ||
+ | <div class="toggle" id="themeToggle">🌙 Dark mode</div> | ||
+ | </div> | ||
+ | |||
+ | <div class="row" style="margin-bottom:8px;"> | ||
+ | <div class="pill" id="status">Wi-Fi: -- dBm · Uptime: --:--:-- · <a href="http://sparrow.local" target="_blank" rel="noreferrer">sparrow.local</a></div> | ||
+ | </div> | ||
+ | |||
+ | <div class="row"> | ||
+ | <div><span class="label">Temperature</span> <span id="t" class="metric">--</span></div> | ||
+ | <div><span class="label">Humidity</span> <span id="h" class="metric">--</span></div> | ||
+ | <div><span class="label">Pressure</span> <span id="p" class="metric">--</span></div> | ||
+ | <div><span class="label">Altitude</span> <span id="a" class="metric">--</span></div> | ||
+ | </div> | ||
+ | |||
+ | <div class="grid" style="margin-top:12px;"> | ||
+ | <div class="card"><div class="title">Temperature (°C)</div><canvas id="ct"></canvas></div> | ||
+ | <div class="card"><div class="title">Humidity (%)</div><canvas id="ch"></canvas></div> | ||
+ | <div class="card"><div class="title">Pressure (hPa)</div><canvas id="cp"></canvas></div> | ||
+ | <div class="card"><div class="title">Altitude (m)</div><canvas id="ca"></canvas></div> | ||
+ | </div> | ||
+ | |||
+ | <div class="ts" id="ts">Waiting for data…</div> | ||
+ | |||
+ | <script> | ||
+ | const secsWindow = 300; // 5 minutes | ||
+ | function $(id){ return document.getElementById(id); } | ||
+ | |||
+ | // ---- Theme toggle (persisted) ---- | ||
+ | (function initTheme(){ | ||
+ | const saved = localStorage.getItem('theme') || 'light'; | ||
+ | document.documentElement.setAttribute('data-theme', saved); | ||
+ | $('themeToggle').textContent = saved === 'dark' ? '☀️ Light mode' : '🌙 Dark mode'; | ||
+ | $('themeToggle').addEventListener('click', () => { | ||
+ | const cur = document.documentElement.getAttribute('data-theme') || 'light'; | ||
+ | const next = cur === 'dark' ? 'light' : 'dark'; | ||
+ | document.documentElement.setAttribute('data-theme', next); | ||
+ | localStorage.setItem('theme', next); | ||
+ | $('themeToggle').textContent = next === 'dark' ? '☀️ Light mode' : '🌙 Dark mode'; | ||
+ | }); | ||
+ | })(); | ||
+ | |||
+ | // ---- Chart helpers ---- | ||
+ | function decimalsForRange(range){ | ||
+ | if (!isFinite(range) || range <= 0) return 1; | ||
+ | if (range < 2) return 2; | ||
+ | if (range < 20) return 1; | ||
+ | return 0; | ||
+ | } | ||
+ | function drawSeries(canvas, secondsAgo, values, color) { | ||
+ | const dpr = window.devicePixelRatio || 1; | ||
+ | const padL = 48 * dpr, padR = 8 * dpr, padT = 8 * dpr, padB = 18 * dpr; | ||
+ | |||
+ | const ctx = canvas.getContext('2d'); | ||
+ | const w = canvas.width = Math.max(10, canvas.clientWidth * dpr); | ||
+ | const h = canvas.height = Math.max(10, canvas.clientHeight * dpr); | ||
+ | |||
+ | ctx.clearRect(0,0,w,h); | ||
+ | if (!values || values.length < 2) return; | ||
+ | |||
+ | const clean = values.filter(v => isFinite(v)); | ||
+ | if (clean.length < 2) return; | ||
+ | |||
+ | let vMin = Math.min(...clean); | ||
+ | let vMax = Math.max(...clean); | ||
+ | if (vMin === vMax) { vMin -= 0.5; vMax += 0.5; } | ||
+ | const pad = (vMax - vMin) * 0.08; | ||
+ | const yMin = vMin - pad; | ||
+ | const yMax = vMax + pad; | ||
+ | const yRange = yMax - yMin; | ||
+ | const yDec = decimalsForRange(yRange); | ||
+ | |||
+ | const plotW = w - padL - padR; | ||
+ | const plotH = h - padT - padB; | ||
+ | |||
+ | // Axes + grid | ||
+ | ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); | ||
+ | ctx.lineWidth = 1 * dpr; | ||
+ | ctx.beginPath(); ctx.moveTo(padL, padT); ctx.lineTo(padL, h - padB); ctx.stroke(); | ||
+ | ctx.beginPath(); ctx.moveTo(padL, h - padB); ctx.lineTo(w - padR, h - padB); ctx.stroke(); | ||
+ | |||
+ | ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--grid').trim(); | ||
+ | ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); | ||
+ | ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; | ||
+ | ctx.font = `${12*dpr}px system-ui,-apple-system,Segoe UI,Roboto,sans-serif`; | ||
+ | for (let i = 0; i <= 5; i++) { | ||
+ | const frac = i / 5; | ||
+ | const yVal = yMax - yRange * frac; | ||
+ | const y = padT + plotH * frac; | ||
+ | ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke(); | ||
+ | ctx.fillText(yVal.toFixed(yDec), padL - 6*dpr, y); | ||
+ | } | ||
+ | |||
+ | const xForSec = s => padL + plotW * (1 - Math.min(s, secsWindow) / secsWindow); | ||
+ | const yForVal = v => padT + plotH * ((yMax - v) / yRange); | ||
+ | |||
+ | ctx.strokeStyle = color || getComputedStyle(document.documentElement).getPropertyValue('--line').trim(); | ||
+ | ctx.lineWidth = Math.max(1, 1.5 * dpr); | ||
+ | ctx.beginPath(); | ||
+ | for (let i = 0; i < values.length; i++) { | ||
+ | const x = xForSec(secondsAgo[i]); | ||
+ | const y = yForVal(values[i]); | ||
+ | if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); | ||
+ | } | ||
+ | ctx.stroke(); | ||
+ | |||
+ | // X labels | ||
+ | ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); | ||
+ | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | ||
+ | const xLabels = [{s:300, txt:'-5m'}, {s:150, txt:'-2.5m'}, {s:5, txt:'now'}]; | ||
+ | xLabels.forEach(l=>{ | ||
+ | const x = xForSec(l.s); | ||
+ | ctx.fillText(l.txt, x, h - padB + 4*dpr); | ||
+ | }); | ||
+ | } | ||
+ | |||
+ | // ---- Data refresh ---- | ||
+ | async function refreshHistory() { | ||
+ | try { | ||
+ | const r = await fetch('/api/history', {cache:'no-store'}); | ||
+ | const j = await r.json(); | ||
+ | if (!j.ok) { $('ts').textContent = 'No data'; return; } | ||
+ | |||
+ | const n = j.n || 0; | ||
+ | if (n>0) { | ||
+ | $('t').textContent = j.tc[n-1].toFixed(2)+' °C'; | ||
+ | $('h').textContent = j.rh[n-1].toFixed(1)+' %'; | ||
+ | $('p').textContent = j.p[n-1].toFixed(2)+' hPa'; | ||
+ | $('a').textContent = j.alt[n-1].toFixed(1)+' m'; | ||
+ | } | ||
+ | |||
+ | drawSeries($('ct'), j.s, j.tc, '#e4572e'); | ||
+ | drawSeries($('ch'), j.s, j.rh, '#17bebb'); | ||
+ | drawSeries($('cp'), j.s, j.p, '#4a7'); | ||
+ | drawSeries($('ca'), j.s, j.alt,'#999'); | ||
+ | |||
+ | $('ts').textContent = 'Updated: ' + new Date().toLocaleTimeString(); | ||
+ | } catch { | ||
+ | $('ts').textContent = 'Fetch error'; | ||
+ | } | ||
+ | } | ||
+ | |||
+ | function fmtUptime(sec){ | ||
+ | const h = Math.floor(sec/3600); | ||
+ | const m = Math.floor((sec%3600)/60); | ||
+ | const s = sec%60; | ||
+ | const pad = n => n.toString().padStart(2,'0'); | ||
+ | return `${pad(h)}:${pad(m)}:${pad(s)}`; | ||
+ | } | ||
+ | |||
+ | async function refreshStatus(){ | ||
+ | try { | ||
+ | const r = await fetch('/api/status', {cache:'no-store'}); | ||
+ | const j = await r.json(); | ||
+ | const rssi = (j && Number.isFinite(j.rssi_dbm)) ? `${j.rssi_dbm} dBm` : '-- dBm'; | ||
+ | const up = (j && Number.isFinite(j.uptime_s)) ? fmtUptime(j.uptime_s) : '--:--:--'; | ||
+ | $('status').innerHTML = `Wi-Fi: ${rssi} · Uptime: ${up} · <a href="http://${j.hostname||'sparrow'}.local" target="_blank" rel="noreferrer">${j.hostname||'sparrow'}.local</a>`; | ||
+ | } catch { | ||
+ | $('status').textContent = 'Wi-Fi: -- dBm · Uptime: --:--:-- · sparrow.local'; | ||
+ | } | ||
+ | } | ||
+ | |||
+ | refreshHistory(); | ||
+ | refreshStatus(); | ||
+ | setInterval(refreshHistory, 2000); | ||
+ | setInterval(refreshStatus, 5000); | ||
+ | </script> | ||
+ | </body> | ||
+ | </html> | ||
+ | |||
+ | |||
+ | </code> | ||
+ | |||
+ | <note warning>In order to upload the contents of the /data folder, you will need to run "Upload Filesystem Image". | ||
+ | </note> | ||
+ | |||
+ | === 6. Advertise on BLE === | ||
+ | |||
+ | Build the example below, which advertises the board on BLE. Install on your phone an app that scans nearby Bluetooth devices, such as [[https://play.google.com/store/apps/details?id=no.nordicsemi.android.mcp&hl=en&pli=1| nRF Connect]]. Check if your device is in the list. | ||
+ | |||
+ | <code C main.cpp> | ||
+ | #include <Arduino.h> | ||
+ | #include <NimBLEDevice.h> | ||
+ | |||
+ | static const char* DEVICE_NAME = "ESP32-C6 Demo"; | ||
+ | static NimBLEUUID SERVICE_UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); | ||
+ | static NimBLEUUID CHAR_UUID ("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"); | ||
+ | |||
+ | NimBLEServer* gServer = nullptr; | ||
+ | NimBLEService* gService = nullptr; | ||
+ | NimBLECharacteristic* gChar = nullptr; | ||
+ | |||
+ | void startBLE() { | ||
+ | NimBLEDevice::init(DEVICE_NAME); | ||
+ | |||
+ | gServer = NimBLEDevice::createServer(); | ||
+ | gService = gServer->createService(SERVICE_UUID); | ||
+ | |||
+ | gChar = gService->createCharacteristic( | ||
+ | CHAR_UUID, | ||
+ | NIMBLE_PROPERTY::READ | ||
+ | ); | ||
+ | gChar->setValue("Hello from ESP32-C6!"); | ||
+ | gService->start(); | ||
+ | |||
+ | NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); | ||
+ | |||
+ | // Advertise our service UUID | ||
+ | adv->addServiceUUID(SERVICE_UUID); | ||
+ | |||
+ | // (v2.x) Build advertising + scan-response payloads explicitly | ||
+ | NimBLEAdvertisementData advData; | ||
+ | advData.setFlags(0x06); // LE General Discoverable + BR/EDR Not Supported | ||
+ | |||
+ | NimBLEAdvertisementData scanData; | ||
+ | scanData.setName(DEVICE_NAME); // put the name in scan response | ||
+ | // you can also add manufacturer data here if you want: | ||
+ | // std::string mfg = "\x34\x12C6"; scanData.setManufacturerData(mfg); | ||
+ | |||
+ | adv->setAdvertisementData(advData); | ||
+ | adv->setScanResponseData(scanData); | ||
+ | |||
+ | // Appearance is still supported | ||
+ | adv->setAppearance(0x0200); // Generic Tag | ||
+ | |||
+ | NimBLEDevice::startAdvertising(); | ||
+ | } | ||
+ | |||
+ | void setup() { | ||
+ | Serial.begin(115200); | ||
+ | while (!Serial) { delay(10); } | ||
+ | startBLE(); | ||
+ | Serial.println("Advertising as ESP32-C6 Demo. Open nRF Connect -> Scan."); | ||
+ | } | ||
+ | |||
+ | void loop() { | ||
+ | delay(1000); | ||
+ | } | ||
+ | |||
+ | </code> | ||
{{:iothings:laboratoare:lab1-ble-scanner.jpg?300|}} | {{:iothings:laboratoare:lab1-ble-scanner.jpg?300|}} |