/* * ws_server.cpp – HTTP webserver implementace */ #include "ws_server.h" #include // ─── Globální pointery ──────────────────────────────────────────────────────── static WebServer* gServer = nullptr; static SensorData* gData = nullptr; static SensorConfig* gCfg = nullptr; static Preferences* gPrefs = nullptr; static ModbusClient* gMb = nullptr; // ─── Pomocné ───────────────────────────────────────────────────────────────── static bool isLangCz() { if (gServer->hasArg("lang")) { String l = gServer->arg("lang"); return !(l == "en"); } if (gServer->hasHeader("Cookie")) { String c = gServer->header("Cookie"); if (c.indexOf("lang=en") >= 0) return false; } return true; } static void sendLangCookie(bool cz) { gServer->sendHeader("Set-Cookie", cz ? "lang=cs; Path=/; Max-Age=31536000" : "lang=en; Path=/; Max-Age=31536000"); } static String fmtFloat(float v, int dec = 1) { char buf[20]; snprintf(buf, sizeof(buf), "%.*f", dec, v); return String(buf); } // ─── CSS ───────────────────────────────────────────────────────────────────── static const char CSS[] PROGMEM = R"CSS( :root{ --bg:#0d1117;--bg2:#161b22;--bg3:#21262d; --border:#30363d;--border2:#484f58; --text:#e6edf3;--text2:#8b949e;--text3:#6e7681; --accent:#58a6ff;--accent2:#1f6feb; --green:#3fb950;--red:#f85149;--yellow:#d29922; } *{box-sizing:border-box;margin:0;padding:0} body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh;font-size:14px;line-height:1.5} a{color:var(--accent);text-decoration:none} a:hover{text-decoration:underline} .topbar{background:var(--bg2);border-bottom:1px solid var(--border); padding:0 24px;display:flex;align-items:center;height:52px; position:sticky;top:0;z-index:100} .logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px; color:var(--text);margin-right:32px;white-space:nowrap} .logo-icon{width:28px;height:28px;background:#1f6feb;border-radius:6px; display:flex;align-items:center;justify-content:center;flex-shrink:0} .nav-links{display:flex;gap:2px;flex:1} .nav-link{padding:6px 14px;border-radius:6px;font-size:13px; color:var(--text2);transition:background .15s,color .15s;white-space:nowrap} .nav-link:hover{background:var(--bg3);color:var(--text);text-decoration:none} .nav-link.active{background:var(--accent2);color:#fff} .lang-btn{margin-left:auto;padding:5px 12px;border-radius:20px; background:var(--bg3);border:1px solid var(--border2); color:var(--text2);font-size:12px;white-space:nowrap} .lang-btn:hover{background:var(--border);color:var(--text);text-decoration:none} .main{max-width:1100px;margin:0 auto;padding:28px 20px} h1{font-size:20px;font-weight:600;margin-bottom:20px} h2{font-size:12px;font-weight:600;color:var(--text3);text-transform:uppercase; letter-spacing:.08em;margin:28px 0 12px;padding-bottom:8px; border-bottom:1px solid var(--border)} h2:first-of-type{margin-top:0} .card{background:var(--bg2);border:1px solid var(--border); border-radius:10px;padding:16px 20px;margin-bottom:16px} table{width:100%;border-collapse:collapse;font-size:13px} th{padding:8px 12px;text-align:left;font-weight:600;font-size:11px; color:var(--text3);text-transform:uppercase;letter-spacing:.06em; border-bottom:1px solid var(--border);white-space:nowrap} td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle} tr:last-child td{border-bottom:none} tr:hover td{background:rgba(255,255,255,.02)} .val{font-family:'Courier New',monospace;font-size:13px;color:var(--text)} .unit{color:var(--text3);font-size:12px} .badge-yes{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-size:12px} .badge-no{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:12px} .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .dot-g{background:var(--green);box-shadow:0 0 6px var(--green); animation:pulse 2s ease-in-out infinite} .dot-x{background:var(--text3)} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}} .toolbar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap} .smeta{margin-left:auto;font-size:12px;color:var(--text3);font-family:'Courier New',monospace} .err-banner{background:rgba(248,81,73,.1);border:1px solid var(--red); border-radius:8px;padding:10px 16px;color:var(--red);font-size:13px; margin-bottom:16px;display:flex;align-items:center;gap:8px} .ip-bar{display:flex;align-items:center;gap:20px;padding:10px 16px; background:var(--bg3);border:1px solid var(--border);border-radius:8px; font-family:'Courier New',monospace;font-size:12px;color:var(--text2); margin-bottom:20px;flex-wrap:wrap} .ip-lbl{color:var(--text3)} .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px; border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent; font-family:inherit;transition:background .15s;text-decoration:none} .btn:active{opacity:.8} .btn-p{background:var(--accent2);color:#fff;border-color:var(--accent2)} .btn-p:hover{background:#388bfd;text-decoration:none;color:#fff} .btn-d{background:var(--bg3);color:var(--text);border-color:var(--border2)} .btn-d:hover{background:var(--border);text-decoration:none} .btn-w{background:transparent;color:var(--yellow);border-color:var(--yellow)} .btn-w:hover{background:rgba(210,153,34,.1);text-decoration:none} .btn-sm{padding:4px 10px;font-size:12px} .fg{margin-bottom:18px} label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:5px} .ht{font-size:11px;color:var(--text3);margin-top:4px} input[type=number],input[type=text],select{ background:var(--bg3);border:1px solid var(--border2);border-radius:6px; padding:7px 10px;color:var(--text);font-size:13px;width:100%; max-width:280px;outline:none;transition:border-color .15s;font-family:inherit} input:focus,select:focus{border-color:var(--accent)} select option{background:var(--bg3)} .frow{display:flex;gap:16px;flex-wrap:wrap} .frow .fg{flex:1;min-width:200px} .act-row{display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start} .act-item{flex:1;min-width:200px} .live-dot{width:8px;height:8px;border-radius:50%;background:var(--green); box-shadow:0 0 6px var(--green);animation:pulse 2s ease-in-out infinite; flex-shrink:0;margin-left:auto} .val.flash{animation:valflash .4s ease-out} @keyframes valflash{0%{color:var(--accent)}100%{color:var(--text)}} .raw-panel{background:var(--bg2);border:1px solid var(--border);border-radius:10px; padding:14px 20px;margin-bottom:16px;font-family:'Courier New',monospace; font-size:12px;line-height:1.8} .raw-tx{color:var(--yellow)} .raw-rx{color:var(--green)} .raw-err{color:var(--red)} .raw-label{color:var(--text3);display:inline-block;width:28px} .raw-status{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px; font-weight:600;text-transform:uppercase;margin-left:8px} .raw-status.ok{background:rgba(63,185,80,.15);color:var(--green)} .raw-status.err{background:rgba(248,81,73,.15);color:var(--red)} @media(max-width:640px){ .topbar{padding:0 12px} .logo{margin-right:12px} .main{padding:16px 12px} th,td{padding:7px 8px} input,select{max-width:100%} .smeta{margin-left:0} } )CSS"; // ─── HTML head + nav ───────────────────────────────────────────────────────── static String htmlHead(const I18nStrings& s, bool cz, const char* page) { String langUrl = cz ? "?lang=en" : "?lang=cs"; String out; out.reserve(3000); out += F("" "" ""); out += s.title; out += F("" "
"); return out; } static String htmlFoot() { return F("
"); } // ─── Stránka: Stav ──────────────────────────────────────────────────────────── static void handleRoot() { bool cz = isLangCz(); sendLangCookie(cz); const I18nStrings& s = getStrings(cz); String html = htmlHead(s, cz, "status"); // IP bar html += F("
" "IP: "); html += ETH.localIP().toString(); html += F("MAC: "); html += ETH.macAddress(); html += F("ETH: "); html += ETH.linkSpeed(); html += F(" Mbit/s
"); if (gData->commError) { html += F("
⚠ "); html += s.commError; html += F(": "); html += gData->errorMsg; html += F("
"); } // Toolbar html += F("
" "
" "
" "" ""); html += F("
"); // Tabulka detekce html += F("

"); html += s.sectionSensors; html += F("

" ""); auto row = [&](const char* name, bool present) { html += F(""); }; const SensorPresence& p = gData->present; row(s.snsWind, p.wind); row(s.snsTempHum, p.tempHum); row(s.snsPressure, p.pressure); row(s.snsNoise, p.noise); row(s.snsPM25, p.pm25); row(s.snsPM10, p.pm10); row(s.snsCO2, p.co2); row(s.snsLight, p.light); row(s.snsRainfall, p.rainfall); row(s.snsCompass, p.compass); row(s.snsSolar, p.solar); html += F("
"); html += s.colSensor; html += F(""); html += s.colPresent; html += F("
"); html += name; html += F(""); if (present) { html += F(""); html += s.yes; } else { html += F(""); html += s.no; } html += F("
"); // Tabulka hodnot html += F("

"); html += s.sectionValues; html += F("

"); auto valRow = [&](const char* id, const char* name, bool present, const String& val, const char* unit) { html += F(""); }; const SensorData& d = *gData; if (p.wind) { valRow("ws", s.snsWindSpeed, true, fmtFloat(d.windSpeed), "m/s"); valRow("wf", s.snsWindForce, true, String(d.windForce), "Bft"); String dirTxt = String(d.windDir8) + " (" + s.windDirNames[d.windDir8 < 8 ? d.windDir8 : 0] + ")"; valRow("wd", s.snsWindDir, true, dirTxt, ""); valRow("w3", s.snsWindDir360, true, String(d.windDir360), "°"); } if (p.tempHum) { valRow("te", s.snsTemp, true, fmtFloat(d.temperature), "°C"); valRow("hu", s.snsHum, true, fmtFloat(d.humidity), "%RH"); } if (p.pressure) valRow("pr", s.snsPressure, true, fmtFloat(d.pressure,2), "kPa"); if (p.noise) valRow("no", s.snsNoise, true, fmtFloat(d.noise), "dB"); if (p.pm25) valRow("p2", s.snsPM25, true, String(d.pm25), "µg/m³"); if (p.pm10) valRow("p1", s.snsPM10, true, String(d.pm10), "µg/m³"); if (p.co2) valRow("co", s.snsCO2, true, String(d.co2), "ppm"); if (p.light) valRow("lx", s.snsLight, true, String(d.luxFull), "lux"); if (p.rainfall) valRow("rn", s.snsRainfall, true, fmtFloat(d.rainfall), "mm"); if (p.compass) valRow("cp", s.snsCompass, true, fmtFloat(d.compass,1), "°"); if (p.solar) valRow("sl", s.snsSolar, true, String(d.solar), "W/m²"); html += F("
"); html += s.colSensor; html += F(""); html += s.colValue; html += F(""); html += s.colUnit; html += F("
"); html += name; html += F(""); html += present ? val : s.noData; html += F(""); html += present ? unit : ""; html += F("
"); // ── Raw RS485 panel ──────────────────────────────────────────────────── html += F("

"); html += s.sectionRaw; html += F("

" "
TX: ..." "
" "
RX: ...
" "
"); // Inline JavaScript – AJAX auto-refresh html += F(""); html += htmlFoot(); gServer->send(200, "text/html; charset=UTF-8", html); } // ─── POST /detect ───────────────────────────────────────────────────────────── static void handleDetect() { detectSensors(*gMb, *gData, *gCfg); bool cz = isLangCz(); sendLangCookie(cz); gServer->sendHeader("Location", cz ? "/?lang=cs" : "/?lang=en"); gServer->send(303); } // ─── GET /config ────────────────────────────────────────────────────────────── static void handleConfig() { bool cz = isLangCz(); sendLangCookie(cz); const I18nStrings& s = getStrings(cz); String html = htmlHead(s, cz, "config"); html += F("

"); html += s.pageConfigTitle; html += F("

"); html += F(""); // Komunikace html += F("

"); html += s.sectionComm; html += F("

"); html += F("
"); html += s.cfgAddrHelp; html += F("
"); html += F("
"); html += s.cfgBaudHelp; html += F("
"); html += F("
"); html += s.cfgPollHelp; html += F("
"); // Kalibrace html += F("

"); html += s.sectionCalib; html += F("

"); html += F("
"); html += s.cfgWindDirOffsetHelp; html += F("
"); char rainHex[8]; snprintf(rainHex, sizeof(rainHex), "0x%02X", gCfg->rainSensitivity); html += F("
"); html += s.cfgRainSensHelp; html += F("
"); html += F("
"); html += s.cfgSlot507Help; html += F("
"); html += F("
" "
"); // Akce html += F("

"); html += s.sectionActions; html += F("

"); html += F("
" "
" "" "
"); html += s.btnWindZeroHelp; html += F("
"); html += F("
" "
" "" "
"); html += s.btnRainZeroHelp; html += F("
"); html += htmlFoot(); gServer->send(200, "text/html; charset=UTF-8", html); } // ─── POST /config ───────────────────────────────────────────────────────────── static void handleConfigPost() { bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true; if (gServer->hasArg("addr")) { gCfg->modbusAddr = (uint8_t)constrain(gServer->arg("addr").toInt(), 1, 247); } if (gServer->hasArg("baud")) { uint32_t b = (uint32_t)gServer->arg("baud").toInt(); if (b != gCfg->baudRate) { gCfg->baudRate = b; gMb->setBaudRate(b); } } if (gServer->hasArg("poll")) { gCfg->pollInterval = (uint8_t)constrain(gServer->arg("poll").toInt(), 1, 60); } if (gServer->hasArg("wdOffset")) { uint8_t v = (uint8_t)(gServer->arg("wdOffset").toInt() & 0x01); if (v != gCfg->windDirOffset) { gCfg->windDirOffset = v; gMb->writeRegister(gCfg->modbusAddr, REG_WIND_DIR_OFFSET, v); } } if (gServer->hasArg("rainSens")) { uint8_t v = (uint8_t)strtol(gServer->arg("rainSens").c_str(), nullptr, 16); if (v != gCfg->rainSensitivity) { gCfg->rainSensitivity = v; gMb->writeRegister(gCfg->modbusAddr, REG_RAIN_SENS, v); } } if (gServer->hasArg("slot507")) { gCfg->slot507Mode = (uint8_t)constrain(gServer->arg("slot507").toInt(), 0, 2); } gPrefs->begin("weather", false); saveConfig(*gPrefs, *gCfg); gPrefs->end(); Serial.println(F("[CFG] Konfigurace ulozena.")); sendLangCookie(cz); gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en"); gServer->send(303); } // ─── POST /action ───────────────────────────────────────────────────────────── static void handleAction() { bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true; String act = gServer->arg("act"); if (act == "wind_zero") { ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_WIND_ZERO, 0xAA); Serial.printf("[ACT] Wind zero: %s\n", gMb->statusStr(st)); } else if (act == "rain_zero") { ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_RAIN_ZERO, 0x5A); Serial.printf("[ACT] Rain zero: %s\n", gMb->statusStr(st)); } sendLangCookie(cz); gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en"); gServer->send(303); } // ─── GET /api/data ──────────────────────────────────────────────────────────── static void handleApiData() { const SensorData& d = *gData; String json = "{"; json += "\"ts\":" + String(d.lastUpdateMs) + ","; json += "\"err\":" + String(d.commError ? "true" : "false"); if (d.present.wind) { json += ",\"windSpeed\":" + fmtFloat(d.windSpeed); json += ",\"windForce\":" + String(d.windForce); json += ",\"windDir8\":" + String(d.windDir8); json += ",\"windDir360\":" + String(d.windDir360); } if (d.present.tempHum) { json += ",\"temp\":" + fmtFloat(d.temperature); json += ",\"hum\":" + fmtFloat(d.humidity); } if (d.present.pressure) json += ",\"pres\":" + fmtFloat(d.pressure,2); if (d.present.noise) json += ",\"noise\":" + fmtFloat(d.noise); if (d.present.pm25) json += ",\"pm25\":" + String(d.pm25); if (d.present.pm10) json += ",\"pm10\":" + String(d.pm10); if (d.present.co2) json += ",\"co2\":" + String(d.co2); if (d.present.light) json += ",\"lux\":" + String(d.luxFull); if (d.present.rainfall) json += ",\"rain\":" + fmtFloat(d.rainfall); if (d.present.compass) json += ",\"compass\":" + fmtFloat(d.compass); if (d.present.solar) json += ",\"solar\":" + String(d.solar); json += "}"; gServer->sendHeader("Access-Control-Allow-Origin", "*"); gServer->send(200, "application/json", json); } // ─── GET /api/status ────────────────────────────────────────────────────────── static void handleApiStatus() { const SensorPresence& p = gData->present; String json = "{"; json += "\"wind\":" + String(p.wind ?"true":"false"); json += ",\"tempHum\":" + String(p.tempHum ?"true":"false"); json += ",\"pressure\":" + String(p.pressure?"true":"false"); json += ",\"noise\":" + String(p.noise ?"true":"false"); json += ",\"pm25\":" + String(p.pm25 ?"true":"false"); json += ",\"pm10\":" + String(p.pm10 ?"true":"false"); json += ",\"co2\":" + String(p.co2 ?"true":"false"); json += ",\"light\":" + String(p.light ?"true":"false"); json += ",\"rainfall\":" + String(p.rainfall?"true":"false"); json += ",\"compass\":" + String(p.compass ?"true":"false"); json += ",\"solar\":" + String(p.solar ?"true":"false"); json += "}"; gServer->sendHeader("Access-Control-Allow-Origin", "*"); gServer->send(200, "application/json", json); } // ─── GET /api/raw – Poslední TX/RX frame ───────────────────────────────────── static String hexBuf(const uint8_t* buf, uint8_t len) { String s; s.reserve(len * 3); for (uint8_t i = 0; i < len; i++) { char h[4]; snprintf(h, sizeof(h), "%02X ", buf[i]); s += h; } if (s.length() > 0) s.remove(s.length() - 1); return s; } static void handleApiRaw() { String json = "{"; json += "\"tx\":\"" + hexBuf(gMb->lastTx, gMb->lastTxLen) + "\""; json += ",\"rx\":\"" + hexBuf(gMb->lastRx, gMb->lastRxLen) + "\""; json += ",\"s\":\"" + String(gMb->statusStr(gMb->lastResult)) + "\""; json += "}"; gServer->sendHeader("Access-Control-Allow-Origin", "*"); gServer->send(200, "application/json", json); } // ─── Inicializace ───────────────────────────────────────────────────────────── void webServerBegin(SensorData& data, SensorConfig& cfg, Preferences& prefs, ModbusClient& mb) { gData = &data; gCfg = &cfg; gPrefs = &prefs; gMb = &mb; gServer = new WebServer(80); static const char* hdrs[] = {"Cookie"}; gServer->collectHeaders(hdrs, 1); gServer->on("/", HTTP_GET, handleRoot); gServer->on("/detect", HTTP_POST, handleDetect); gServer->on("/config", HTTP_GET, handleConfig); gServer->on("/config", HTTP_POST, handleConfigPost); gServer->on("/action", HTTP_POST, handleAction); gServer->on("/api/data", HTTP_GET, handleApiData); gServer->on("/api/status", HTTP_GET, handleApiStatus); gServer->on("/api/raw", HTTP_GET, handleApiRaw); gServer->onNotFound([]() { gServer->send(404, "text/plain", "Not found"); }); gServer->begin(); Serial.println(F("[WEB] HTTP server spusten na portu 80")); } void webServerHandle() { if (gServer) gServer->handleClient(); }