This shows you the differences between two versions of the page.
|
iothings:laboratoare:2025:lab1 [2025/09/25 21:55] dan.tudose |
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 270: | Line 270: | ||
| - | === 5. Advertise on BLE === | + | === 6. Web Server === |
| - | 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. | + | 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> | <code C main.cpp> | ||
| #include <Arduino.h> | #include <Arduino.h> | ||
| - | #include <NimBLEDevice.h> | + | #include <Wire.h> |
| + | #include <WiFi.h> | ||
| + | #include <WebServer.h> | ||
| + | #include <SPIFFS.h> | ||
| + | #include <ESPmDNS.h> | ||
| + | #include <Adafruit_BME680.h> | ||
| - | static const char* DEVICE_NAME = "ESP32-C6 Demo"; | + | #define SEALEVEL_HPA (1013.25f) // adjust for better altitude accuracy |
| - | static NimBLEUUID SERVICE_UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); | + | |
| - | static NimBLEUUID CHAR_UUID ("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"); | + | |
| - | NimBLEServer* gServer = nullptr; | + | // ===== WiFi ===== |
| - | NimBLEService* gService = nullptr; | + | const char* WIFI_SSID = "YOUR_SSID"; |
| - | NimBLECharacteristic* gChar = nullptr; | + | const char* WIFI_PASS = "YOUR_PASSWORD"; |
| + | const char* MDNS_NAME = "sparrow"; // -> http://sparrow.local/ | ||
| - | void startBLE() { | + | // ===== Web ===== |
| - | NimBLEDevice::init(DEVICE_NAME); | + | WebServer server(80); |
| - | gServer = NimBLEDevice::createServer(); | + | // ===== BME680 ===== |
| - | gService = gServer->createService(SERVICE_UUID); | + | Adafruit_BME680 bme; |
| + | bool haveBME = false; | ||
| - | gChar = gService->createCharacteristic( | + | // ===== History buffer (5 minutes @ 1 Hz) ===== |
| - | CHAR_UUID, | + | static const size_t HISTORY_MAX = 300; |
| - | NIMBLE_PROPERTY::READ | + | |
| - | ); | + | |
| - | gChar->setValue("Hello from ESP32-C6!"); | + | |
| - | gService->start(); | + | |
| - | NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); | + | struct Sample { |
| + | uint32_t t_ms; | ||
| + | float tC; | ||
| + | float hPct; | ||
| + | float pHpa; | ||
| + | float altM; | ||
| + | }; | ||
| - | // Advertise our service UUID | + | Sample hist[HISTORY_MAX]; |
| - | adv->addServiceUUID(SERVICE_UUID); | + | size_t histHead = 0; |
| + | size_t histCount = 0; | ||
| - | // (v2.x) Build advertising + scan-response payloads explicitly | + | void pushSample(const Sample& s) { |
| - | NimBLEAdvertisementData advData; | + | hist[histHead] = s; |
| - | advData.setFlags(0x06); // LE General Discoverable + BR/EDR Not Supported | + | histHead = (histHead + 1) % HISTORY_MAX; |
| + | if (histCount < HISTORY_MAX) histCount++; | ||
| + | } | ||
| - | NimBLEAdvertisementData scanData; | + | Sample getSampleByAge(size_t iOldToNew) { |
| - | scanData.setName(DEVICE_NAME); // put the name in scan response | + | size_t idx = (histHead + HISTORY_MAX - histCount + iOldToNew) % HISTORY_MAX; |
| - | // you can also add manufacturer data here if you want: | + | return hist[idx]; |
| - | // std::string mfg = "\x34\x12C6"; scanData.setManufacturerData(mfg); | + | } |
| - | adv->setAdvertisementData(advData); | + | // ===== Sensor init and reading ===== |
| - | adv->setScanResponseData(scanData); | + | bool beginBME680() { |
| + | Wire.begin(21, 22); // set custom SDA/SCL here if needed: Wire.begin(SDA, SCL); | ||
| - | // Appearance is still supported | + | if (bme.begin(0x76, &Wire)) { Serial.println("[BME680] Found at 0x76"); return true; } |
| - | adv->setAppearance(0x0200); // Generic Tag | + | 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; | ||
| - | NimBLEDevice::startAdvertising(); | + | 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); | ||
| } | } | ||
| Line 325: | Line 436: | ||
| Serial.begin(115200); | Serial.begin(115200); | ||
| while (!Serial) { delay(10); } | while (!Serial) { delay(10); } | ||
| - | startBLE(); | + | Serial.println("\n[BOOT] ESP32-C6 BME680 Web Graphs + Status + mDNS"); |
| - | Serial.println("Advertising as ESP32-C6 Demo. Open nRF Connect -> Scan."); | + | |
| + | 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() { | void loop() { | ||
| - | delay(1000); | + | server.handleClient(); |
| + | |||
| + | // 1 Hz sampling | ||
| + | static uint32_t lastSample = 0; | ||
| + | uint32_t now = millis(); | ||
| + | if (now - lastSample >= 1000) { | ||
| + | lastSample = now; | ||
| + | takeAndStoreSample(); | ||
| + | } | ||
| + | delay(2); | ||
| } | } | ||
| + | |||
| </code> | </code> | ||
| - | {{:iothings:laboratoare:lab1-ble-scanner.jpg?300|}} | + | 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> | ||
| + | |||
| ===== Resources ===== | ===== Resources ===== | ||