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/10/24 21:12] (current) dan.tudose [Lab 1. WiFi Server] |
||
|---|---|---|---|
| Line 1: | Line 1: | ||
| - | ===== Lab 1: Getting Started ====== | + | ===== Lab 1. Getting Started. WiFi Basics ====== |
| ===== Necessary gear ===== | ===== Necessary gear ===== | ||
| 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 === |
| + | |||
| + | 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> | ||
| - | 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. | ||
| - | {{:iothings:laboratoare:lab1-ble-scanner.jpg?300|}} | ||
| ===== Resources ===== | ===== Resources ===== | ||