/* * ws_server.cpp – HTTP webserver implementace * ZTS-3000-FSJT-N01 */ #include "ws_server.h" #include // ─── Globalni pointery ──────────────────────────────────────────────────────── static WebServer* gServer = nullptr; static SensorData* gData = nullptr; static SensorConfig* gCfg = nullptr; static Preferences* gPrefs = nullptr; static ModbusClient* gMb = nullptr; // ─── Pomocne ───────────────────────────────────────────────────────────────── 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)} .bigval{display:flex;align-items:baseline;gap:8px;margin:4px 0} .bigval .num{font-size:34px;font-weight:700;color:var(--accent);font-family:'Courier New',monospace} .bigval .un{font-size:14px;color:var(--text2)} @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("
"); } // ─── Stranka: 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("

" ""); const SensorPresence& p = gData->present; html += F(""); html += F("
"); html += s.colSensor; html += F(""); html += s.colPresent; html += F("
"); html += s.snsWind; html += F(""); if (p.wind) { html += F(""); html += s.yes; } else { html += F(""); html += s.no; } html += F("
"); // Tabulka hodnot html += F("

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

"); const SensorData& d = *gData; const BeaufortInfo& bi = beaufortInfo(d.windLevel); const char* bName = cz ? bi.nameCz : bi.nameEn; // Rychlost vetru html += F(""); // Sila vetru html += F(""); // Slovni popis html += F(""); html += F("
"); html += s.colSensor; html += F(""); html += s.colValue; html += F(""); html += s.colUnit; html += F("
"); html += s.snsWindSpeed; html += F(""); html += p.wind ? fmtFloat(d.windSpeed) : String(s.noData); html += F(""); html += p.wind ? s.unitMs : ""; html += F("
"); html += s.snsWindLevel; html += F(""); html += p.wind ? String(d.windLevel) : String(s.noData); html += F(""); html += p.wind ? s.unitBft : ""; html += F("
"); html += s.snsWindLevelName; html += F(""); html += p.wind ? bName : s.noData; 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("
"); html += F("
" "
"); // Akce – zmena adresy snimace html += F("

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

" "
" "" "
" "
"); html += s.btnChangeAddrHelp; 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); } gPrefs->begin("zts3000", 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 == "change_addr" && gServer->hasArg("newAddr")) { uint8_t newAddr = (uint8_t)constrain(gServer->arg("newAddr").toInt(), 1, 247); ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_DEVICE_ADDR, newAddr); Serial.printf("[ACT] Change addr %u -> %u : %s\n", gCfg->modbusAddr, newAddr, gMb->statusStr(st)); if (st == ModbusStatus::OK) { gCfg->modbusAddr = newAddr; gPrefs->begin("zts3000", false); saveConfig(*gPrefs, *gCfg); gPrefs->end(); } } 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 += ",\"windLevel\":" + String(d.windLevel); } 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 += "}"; gServer->sendHeader("Access-Control-Allow-Origin", "*"); gServer->send(200, "application/json", json); } // ─── GET /api/raw – Posledni 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(); }