This shows you the differences between two versions of the page.
iothings:laboratoare:2025:lab1 [2025/09/25 22:10] dan.tudose [First Steps] |
iothings:laboratoare:2025:lab1 [2025/09/25 22:37] (current) dan.tudose |
||
---|---|---|---|
Line 282: | Line 282: | ||
#include <WebServer.h> | #include <WebServer.h> | ||
#include <SPIFFS.h> | #include <SPIFFS.h> | ||
+ | #include <ESPmDNS.h> | ||
#include <Adafruit_BME680.h> | #include <Adafruit_BME680.h> | ||
- | #define SEALEVEL_HPA (1019.9f) // adjust for better altitude accuracy | + | #define SEALEVEL_HPA (1013.25f) // adjust for better altitude accuracy |
// ===== WiFi ===== | // ===== WiFi ===== | ||
- | const char* WIFI_SSID = "TP-Link_2A64"; | + | const char* WIFI_SSID = "YOUR_SSID"; |
- | const char* WIFI_PASS = "99481100"; | + | const char* WIFI_PASS = "YOUR_PASSWORD"; |
+ | const char* MDNS_NAME = "sparrow"; // -> http://sparrow.local/ | ||
// ===== Web ===== | // ===== Web ===== | ||
Line 301: | Line 303: | ||
struct Sample { | struct Sample { | ||
- | uint32_t t_ms; // millis() at sample time | + | uint32_t t_ms; |
- | float tC; // °C | + | float tC; |
- | float hPct; // % | + | float hPct; |
- | float pHpa; // hPa | + | float pHpa; |
- | float altM; // meters | + | float altM; |
}; | }; | ||
Sample hist[HISTORY_MAX]; | Sample hist[HISTORY_MAX]; | ||
- | size_t histHead = 0; // next write index | + | size_t histHead = 0; |
- | size_t histCount = 0; // how many valid samples | + | size_t histCount = 0; |
void pushSample(const Sample& s) { | void pushSample(const Sample& s) { | ||
Line 319: | Line 321: | ||
Sample getSampleByAge(size_t iOldToNew) { | Sample getSampleByAge(size_t iOldToNew) { | ||
- | // iOldToNew: 0=oldest .. histCount-1=newest | ||
size_t idx = (histHead + HISTORY_MAX - histCount + iOldToNew) % HISTORY_MAX; | size_t idx = (histHead + HISTORY_MAX - histCount + iOldToNew) % HISTORY_MAX; | ||
return hist[idx]; | return hist[idx]; | ||
Line 328: | Line 329: | ||
Wire.begin(21, 22); // set custom SDA/SCL here if needed: Wire.begin(SDA, SCL); | Wire.begin(21, 22); // set custom SDA/SCL here if needed: Wire.begin(SDA, SCL); | ||
- | if (bme.begin(0x76, &Wire)) { | + | if (bme.begin(0x76, &Wire)) { Serial.println("[BME680] Found at 0x76"); return true; } |
- | Serial.println("[BME680] Found at 0x76"); | + | if (bme.begin(0x77, &Wire)) { Serial.println("[BME680] Found at 0x77"); return true; } |
- | return true; | + | |
- | } | + | |
- | if (bme.begin(0x77, &Wire)) { | + | |
- | Serial.println("[BME680] Found at 0x77"); | + | |
- | return true; | + | |
- | } | + | |
Serial.println("[BME680] Not found at 0x76/0x77"); | Serial.println("[BME680] Not found at 0x76/0x77"); | ||
return false; | return false; | ||
Line 345: | Line 340: | ||
bme.setPressureOversampling(BME680_OS_4X); | bme.setPressureOversampling(BME680_OS_4X); | ||
bme.setIIRFilterSize(BME680_FILTER_SIZE_3); | bme.setIIRFilterSize(BME680_FILTER_SIZE_3); | ||
- | bme.setGasHeater(320, 150); // gas heater on; harmless if gas not used | + | bme.setGasHeater(320, 150); // optional |
} | } | ||
Line 371: | Line 366: | ||
void handleApiSensor() { | void handleApiSensor() { | ||
- | // Return the latest reading (take one now if buffer is empty) | ||
if (histCount == 0) takeAndStoreSample(); | if (histCount == 0) takeAndStoreSample(); | ||
Sample s = (histCount == 0) ? Sample{millis(), NAN, NAN, NAN, NAN} | Sample s = (histCount == 0) ? Sample{millis(), NAN, NAN, NAN, NAN} | ||
Line 391: | Line 385: | ||
if (n == 0) { server.send(200, "application/json", "{\"ok\":true,\"n\":0}"); return; } | if (n == 0) { server.send(200, "application/json", "{\"ok\":true,\"n\":0}"); return; } | ||
- | // Build JSON: seconds-ago array + each metric | ||
String json; | String json; | ||
json.reserve(n * 64 + 128); | json.reserve(n * 64 + 128); | ||
json += "{\"ok\":true,\"n\":" + String(n); | json += "{\"ok\":true,\"n\":" + String(n); | ||
- | // seconds ago | + | // seconds ago array |
json += ",\"s\":["; | json += ",\"s\":["; | ||
for (size_t i = 0; i < n; ++i) { | for (size_t i = 0; i < n; ++i) { | ||
Line 407: | Line 400: | ||
auto appendSeries = [&](const char* key, float Sample::*field, int digits) { | auto appendSeries = [&](const char* key, float Sample::*field, int digits) { | ||
- | json += ",\""; | + | json += ",\""; json += key; json += "\":["; |
- | json += key; | + | |
- | json += "\":["; | + | |
for (size_t i = 0; i < n; ++i) { | for (size_t i = 0; i < n; ++i) { | ||
Sample s = getSampleByAge(i); | Sample s = getSampleByAge(i); | ||
Line 423: | Line 414: | ||
appendSeries("alt", &Sample::altM, 1); | 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 += "}"; | json += "}"; | ||
server.send(200, "application/json", json); | server.send(200, "application/json", json); | ||
Line 430: | Line 436: | ||
Serial.begin(115200); | Serial.begin(115200); | ||
while (!Serial) { delay(10); } | while (!Serial) { delay(10); } | ||
- | Serial.println("\n[BOOT] ESP32-C6 BME680 Web Graphs"); | + | Serial.println("\n[BOOT] ESP32-C6 BME680 Web Graphs + Status + mDNS"); |
if (!SPIFFS.begin(true)) Serial.println("[SPIFFS] Mount failed"); | if (!SPIFFS.begin(true)) Serial.println("[SPIFFS] Mount failed"); | ||
else Serial.println("[SPIFFS] Mounted"); | else Serial.println("[SPIFFS] Mounted"); | ||
- | // WiFi | + | // WiFi + mDNS |
WiFi.mode(WIFI_STA); | WiFi.mode(WIFI_STA); | ||
WiFi.setSleep(false); | WiFi.setSleep(false); | ||
+ | WiFi.setHostname(MDNS_NAME); | ||
WiFi.begin(WIFI_SSID, WIFI_PASS); | WiFi.begin(WIFI_SSID, WIFI_PASS); | ||
Serial.printf("[WiFi] Connecting to %s", WIFI_SSID); | Serial.printf("[WiFi] Connecting to %s", WIFI_SSID); | ||
Line 448: | Line 455: | ||
if (WiFi.status() == WL_CONNECTED) { | if (WiFi.status() == WL_CONNECTED) { | ||
Serial.printf("[WiFi] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); | 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 { | } else { | ||
Serial.println("[WiFi] Failed to connect (continuing)"); | Serial.println("[WiFi] Failed to connect (continuing)"); | ||
Line 456: | Line 469: | ||
if (haveBME) setupBME680(); | if (haveBME) setupBME680(); | ||
- | // Prime buffer with one sample so the page has data immediately | + | // Prime buffer |
takeAndStoreSample(); | takeAndStoreSample(); | ||
// Routes | // Routes | ||
- | server.on("/", HTTP_GET, handleRoot); | + | server.on("/", HTTP_GET, handleRoot); |
- | server.on("/api/sensor", HTTP_GET, handleApiSensor); | + | server.on("/api/sensor", HTTP_GET, handleApiSensor); |
- | server.on("/api/history", HTTP_GET, handleApiHistory); | + | server.on("/api/history",HTTP_GET, handleApiHistory); |
+ | server.on("/api/status", HTTP_GET, handleApiStatus); | ||
server.onNotFound([]() { | server.onNotFound([]() { | ||
Line 478: | Line 492: | ||
Serial.println("[HTTP] Server started"); | Serial.println("[HTTP] Server started"); | ||
if (WiFi.status() == WL_CONNECTED) | if (WiFi.status() == WL_CONNECTED) | ||
- | Serial.println("[HTTP] Open: http://" + WiFi.localIP().toString() + "/"); | + | Serial.println("[HTTP] Open: http://" + WiFi.localIP().toString() + "/ or http://sparrow.local/"); |
} | } | ||
Line 484: | Line 498: | ||
server.handleClient(); | server.handleClient(); | ||
- | // Take a sample once per second | + | // 1 Hz sampling |
static uint32_t lastSample = 0; | static uint32_t lastSample = 0; | ||
uint32_t now = millis(); | uint32_t now = millis(); | ||
Line 490: | Line 504: | ||
lastSample = now; | lastSample = now; | ||
takeAndStoreSample(); | takeAndStoreSample(); | ||
- | |||
- | // Drop samples older than 5 minutes (optional; ring buffer already caps size) | ||
- | // (kept for clarity if you ever increase HISTORY_MAX) | ||
} | } | ||
- | |||
delay(2); | delay(2); | ||
} | } | ||
+ | |||
</code> | </code> | ||
Line 504: | Line 515: | ||
<code html index.html> | <code html index.html> | ||
<!doctype html> | <!doctype html> | ||
- | <html lang="en"> | + | <html lang="en" data-theme="light"> |
<head> | <head> | ||
<meta charset="utf-8"/> | <meta charset="utf-8"/> | ||
- | <title>ESP32-C6 Sparrow Dashboard</title> | + | <title>BME680 Dashboard</title> |
<meta name="viewport" content="width=device-width, initial-scale=1"/> | <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||
<style> | <style> | ||
- | :root { --fg:#222; --muted:#777; --card:#f7f7f7; } | + | :root { |
- | body { font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; margin: 16px; color: var(--fg); } | + | --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; } | h1 { margin: 0 0 8px 0; } | ||
.row { display:flex; gap:16px; flex-wrap:wrap; align-items:baseline; } | .row { display:flex; gap:16px; flex-wrap:wrap; align-items:baseline; } | ||
Line 521: | Line 539: | ||
canvas { width:100%; height:200px; display:block; } | canvas { width:100%; height:200px; display:block; } | ||
.ts { color: var(--muted); margin-top: 8px; font-size:.9rem; } | .ts { color: var(--muted); margin-top: 8px; font-size:.9rem; } | ||
- | @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } } /* optional: stack on small screens */ | + | @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> | </style> | ||
</head> | </head> | ||
<body> | <body> | ||
- | <h1>ESP32-C6 Sparrow Dashboard</h1> | + | <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 class="row"> | ||
<div><span class="label">Temperature</span> <span id="t" class="metric">--</span></div> | <div><span class="label">Temperature</span> <span id="t" class="metric">--</span></div> | ||
Line 544: | Line 577: | ||
<script> | <script> | ||
const secsWindow = 300; // 5 minutes | const secsWindow = 300; // 5 minutes | ||
- | |||
function $(id){ return document.getElementById(id); } | function $(id){ return document.getElementById(id); } | ||
- | // Simple helper to choose decimals based on range | + | // ---- 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){ | function decimalsForRange(range){ | ||
if (!isFinite(range) || range <= 0) return 1; | if (!isFinite(range) || range <= 0) return 1; | ||
Line 554: | Line 600: | ||
return 0; | return 0; | ||
} | } | ||
- | |||
- | // Draw a line chart with a Y axis (left), ticks, labels | ||
function drawSeries(canvas, secondsAgo, values, color) { | function drawSeries(canvas, secondsAgo, values, color) { | ||
const dpr = window.devicePixelRatio || 1; | const dpr = window.devicePixelRatio || 1; | ||
- | const padL = 48 * dpr, padR = 8 * dpr, padT = 8 * dpr, padB = 18 * dpr; // room for axis/labels | + | const padL = 48 * dpr, padR = 8 * dpr, padT = 8 * dpr, padB = 18 * dpr; |
const ctx = canvas.getContext('2d'); | const ctx = canvas.getContext('2d'); | ||
Line 582: | Line 626: | ||
const plotH = h - padT - padB; | const plotH = h - padT - padB; | ||
- | // Axes | + | // Axes + grid |
- | ctx.strokeStyle = 'rgba(0,0,0,0.35)'; | + | ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); |
ctx.lineWidth = 1 * dpr; | ctx.lineWidth = 1 * dpr; | ||
- | // Y axis line | + | ctx.beginPath(); ctx.moveTo(padL, padT); ctx.lineTo(padL, h - padB); ctx.stroke(); |
- | ctx.beginPath(); | + | ctx.beginPath(); ctx.moveTo(padL, h - padB); ctx.lineTo(w - padR, h - padB); ctx.stroke(); |
- | ctx.moveTo(padL, padT); | + | |
- | ctx.lineTo(padL, h - padB); | + | |
- | ctx.stroke(); | + | |
- | // X axis baseline | + | |
- | ctx.beginPath(); | + | |
- | ctx.moveTo(padL, h - padB); | + | |
- | ctx.lineTo(w - padR, h - padB); | + | |
- | ctx.stroke(); | + | |
- | // Horizontal grid + Y tick labels (6 ticks) | + | ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--grid').trim(); |
- | ctx.strokeStyle = 'rgba(0,0,0,0.07)'; | + | ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); |
- | ctx.fillStyle = 'rgba(0,0,0,0.6)'; | + | ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; |
- | ctx.textAlign = 'right'; | + | |
- | ctx.textBaseline = 'middle'; | + | |
ctx.font = `${12*dpr}px system-ui,-apple-system,Segoe UI,Roboto,sans-serif`; | ctx.font = `${12*dpr}px system-ui,-apple-system,Segoe UI,Roboto,sans-serif`; | ||
for (let i = 0; i <= 5; i++) { | for (let i = 0; i <= 5; i++) { | ||
Line 606: | Line 640: | ||
const yVal = yMax - yRange * frac; | const yVal = yMax - yRange * frac; | ||
const y = padT + plotH * frac; | const y = padT + plotH * frac; | ||
- | // grid line | + | ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke(); |
- | ctx.beginPath(); | + | ctx.fillText(yVal.toFixed(yDec), padL - 6*dpr, y); |
- | ctx.moveTo(padL, y); | + | |
- | ctx.lineTo(w - padR, y); | + | |
- | ctx.stroke(); | + | |
- | // label | + | |
- | const label = yVal.toFixed(yDec); | + | |
- | ctx.fillText(label, padL - 6*dpr, y); | + | |
} | } | ||
- | // Map data points to plot coords | ||
const xForSec = s => padL + plotW * (1 - Math.min(s, secsWindow) / secsWindow); | const xForSec = s => padL + plotW * (1 - Math.min(s, secsWindow) / secsWindow); | ||
const yForVal = v => padT + plotH * ((yMax - v) / yRange); | const yForVal = v => padT + plotH * ((yMax - v) / yRange); | ||
- | // Data line | + | ctx.strokeStyle = color || getComputedStyle(document.documentElement).getPropertyValue('--line').trim(); |
- | ctx.strokeStyle = color || '#0b6'; | + | |
ctx.lineWidth = Math.max(1, 1.5 * dpr); | ctx.lineWidth = Math.max(1, 1.5 * dpr); | ||
ctx.beginPath(); | ctx.beginPath(); | ||
Line 631: | Line 657: | ||
ctx.stroke(); | ctx.stroke(); | ||
- | // X labels (left=-5m, mid=-2.5m, right=now) | + | // X labels |
- | ctx.fillStyle = 'rgba(0,0,0,0.5)'; | + | ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--axis').trim(); |
- | ctx.textAlign = 'center'; | + | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
- | ctx.textBaseline = 'top'; | + | const xLabels = [{s:300, txt:'-5m'}, {s:150, txt:'-2.5m'}, {s:5, txt:'now'}]; |
- | const xLabels = [{s:secsWindow, txt:'-5m'}, {s:secsWindow/2, txt:'-2.5m'}, {s:20, txt:'now'}]; | + | |
xLabels.forEach(l=>{ | xLabels.forEach(l=>{ | ||
const x = xForSec(l.s); | const x = xForSec(l.s); | ||
Line 642: | Line 667: | ||
} | } | ||
- | async function refresh() { | + | // ---- Data refresh ---- |
+ | async function refreshHistory() { | ||
try { | try { | ||
const r = await fetch('/api/history', {cache:'no-store'}); | const r = await fetch('/api/history', {cache:'no-store'}); | ||
const j = await r.json(); | const j = await r.json(); | ||
- | |||
if (!j.ok) { $('ts').textContent = 'No data'; return; } | if (!j.ok) { $('ts').textContent = 'No data'; return; } | ||
Line 660: | Line 685: | ||
drawSeries($('ch'), j.s, j.rh, '#17bebb'); | drawSeries($('ch'), j.s, j.rh, '#17bebb'); | ||
drawSeries($('cp'), j.s, j.p, '#4a7'); | drawSeries($('cp'), j.s, j.p, '#4a7'); | ||
- | drawSeries($('ca'), j.s, j.alt,'#555'); | + | drawSeries($('ca'), j.s, j.alt,'#999'); |
$('ts').textContent = 'Updated: ' + new Date().toLocaleTimeString(); | $('ts').textContent = 'Updated: ' + new Date().toLocaleTimeString(); | ||
- | } catch (e) { | + | } catch { |
$('ts').textContent = 'Fetch error'; | $('ts').textContent = 'Fetch error'; | ||
} | } | ||
} | } | ||
- | refresh(); | + | |
- | setInterval(refresh, 2000); | + | 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> | </script> | ||
</body> | </body> | ||
</html> | </html> | ||
+ | |||
</code> | </code> |