/* * ws_server.cpp – HTTP webserver pro RS485 rele modul (8 / 16 kanalu) */ #include "ws_server.h" #include // ─── Globalni pointery ──────────────────────────────────────────────────────── static WebServer* gServer = nullptr; static RelayState* gState = nullptr; static RelayConfig* 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"); } // ─── 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;--purple:#a371f7; } *{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;color:#fff;font-weight:700} .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)} .ch-num{font-family:'Courier New',monospace;color:var(--text3);width:32px;text-align:center} .state-on{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-weight:600;font-size:12px} .state-off{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:12px} .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0} .dot-g{background:var(--green);box-shadow:0 0 8px var(--green)} .dot-x{background:var(--text3)} .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} .info-banner{background:rgba(88,166,255,.08);border:1px solid var(--border); border-radius:8px;padding:12px 16px;color:var(--text2);font-size:12px; margin-bottom:16px;line-height:1.6} .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:6px 12px; border-radius:6px;font-size:12px;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-pp{background:transparent;color:var(--purple);border-color:var(--purple)} .btn-pp:hover{background:rgba(163,113,247,.1);text-decoration:none;color:var(--purple)} .btn-g{background:var(--green);color:#fff;border-color:var(--green)} .btn-g:hover{background:#46c95a;text-decoration:none;color:#fff} .btn-r{background:var(--red);color:#fff;border-color:var(--red)} .btn-r:hover{background:#ff5e54;text-decoration:none;color:#fff} .btn-sm{padding:4px 10px;font-size:11px} .actions-row{display:flex;gap:5px;flex-wrap:wrap} .bulk-row{display:flex;gap:12px;flex-wrap:wrap} .fg{margin-bottom:14px} 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[type=number]{max-width:120px} 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} .name-input{max-width:180px;font-size:12px;padding:5px 8px} .adv-row{display:flex;align-items:end;gap:8px;flex-wrap:wrap} .adv-row .fg{margin-bottom:0;min-width:0} .adv-inline{display:inline-flex;align-items:center;gap:6px} .adv-inline input[type=number]{width:80px;max-width:80px;padding:5px 8px;font-size:12px} .smeta{font-size:12px;color:var(--text3);font-family:'Courier New',monospace} .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 6px} .actions-row{gap:3px} .btn{padding:5px 8px;font-size:11px} input,select{max-width:100%} } )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: ovladani ──────────────────────────────────────────────────────── 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("Modbus: "); html += gCfg->modbusAddr; html += F(" @ "); html += gCfg->baudRate; html += F(" bpsCH: "); html += gCfg->channelCount; html += F("
"); if (gState->commError) { html += F("
⚠ "); html += s.commError; html += F(": "); html += gState->errorMsg; html += F("
"); } // Tabulka kanalu html += F("

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

" ""); String langInp = cz ? "cs" : "en"; for (uint8_t i = 0; i < gCfg->channelCount; i++) { bool on = gState->channels[i]; html += F(""); } html += F("
"); html += s.colChannel; html += F(""); html += s.colName; html += F(""); html += s.colState; html += F(""); html += s.colCtrl; html += F("
"); html += (i + 1); html += F(""); html += gCfg->names[i]; html += F(""); if (on) { html += F(""); html += s.stateOn; html += F(""); } else { html += F(""); html += s.stateOff; html += F(""); } html += F("
"); auto addBtn = [&](const char* act, const char* cls, const char* label) { html += F("
" "
"); }; addBtn("on", "btn-g", s.btnOn); addBtn("off", "btn-r", s.btnOff); addBtn("toggle", "btn-d", s.btnToggle); html += F("
"); // Specialni rezimy (Latch / Momentary / Delay) ako kompaktni panel html += F("

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

"); html += F("
"); html += s.labelLatchHelp; html += F(" — "); html += s.labelMomentaryHelp; html += F(" — "); html += s.labelDelayHelp; html += F("
"); // Latch / Momentary / Delay – pro kazdy kanal radek html += F(""); for (uint8_t i = 0; i < gCfg->channelCount; i++) { html += F(""); } html += F("
"); html += s.colChannel; html += F(""); html += s.colName; html += F(""); html += s.colCtrl; html += F("
"); html += (i + 1); html += F(""); html += gCfg->names[i]; html += F("
"); // Latch html += F("
" "" "
"); // Momentary (Pulse) html += F("
" "" "
"); // Delay s inputem sekund html += F("
" "" "" "
"); html += F("
"); html += F("
"); // Hromadne ovladani html += F("

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

" "
" "" "
" "
" "" "
" "
"); if (gState->lastAction.length() > 0) { html += F("
"); html += s.lastAction; html += F(": "); html += gState->lastAction; html += F("
"); } html += F("
"); // Raw RS485 panel html += F("

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

" "
TX: ..." "
" "
RX: ...
" "
"); // JS: refresh stavu + raw panelu html += F(""); html += htmlFoot(); gServer->send(200, "text/html; charset=UTF-8", html); } // ─── POST /relay ───────────────────────────────────────────────────────────── static void handleRelay() { bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true; String act = gServer->arg("act"); uint8_t ch = (uint8_t)gServer->arg("ch").toInt(); if (act == "on" && ch >= 1 && ch <= gCfg->channelCount) { relaySet(*gMb, *gState, *gCfg, ch, true); } else if (act == "off" && ch >= 1 && ch <= gCfg->channelCount) { relaySet(*gMb, *gState, *gCfg, ch, false); } else if (act == "toggle" && ch >= 1 && ch <= gCfg->channelCount) { relayToggle(*gMb, *gState, *gCfg, ch); } else if (act == "latch" && ch >= 1 && ch <= gCfg->channelCount) { relayLatch(*gMb, *gState, *gCfg, ch); } else if (act == "momentary" && ch >= 1 && ch <= gCfg->channelCount) { relayMomentary(*gMb, *gState, *gCfg, ch); } else if (act == "delay" && ch >= 1 && ch <= gCfg->channelCount) { uint8_t sec = (uint8_t)constrain(gServer->arg("sec").toInt(), 0, 255); relayDelay(*gMb, *gState, *gCfg, ch, sec); } else if (act == "all_on") { relayAll(*gMb, *gState, *gCfg, true); } else if (act == "all_off") { relayAll(*gMb, *gState, *gCfg, false); } 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("

"); // Info banner o DIP html += F("
"); html += s.infoDip; html += F("
"); 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.cfgChannelsHelp; html += F("
"); html += F("
"); html += s.cfgDefDelayHelp; html += F("
"); html += F("
"); // Nazvy kanalu html += F("

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

"); html += s.cfgNameHelp; html += F("
"); for (uint8_t i = 0; i < gCfg->channelCount; i++) { html += F("
"); } html += F("
"); 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(), MODBUS_ADDR_MIN, MODBUS_ADDR_MAX); } 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("chCount")) { uint8_t c = (uint8_t)gServer->arg("chCount").toInt(); gCfg->channelCount = (c == 16) ? 16 : 8; } if (gServer->hasArg("defDelay")) { gCfg->defaultDelay = (uint8_t)constrain(gServer->arg("defDelay").toInt(), 0, 255); } for (uint8_t i = 0; i < MAX_CHANNEL_COUNT; i++) { String key = String("n") + i; if (gServer->hasArg(key.c_str())) { String v = gServer->arg(key.c_str()); strncpy(gCfg->names[i], v.c_str(), 15); gCfg->names[i][15] = 0; } } gPrefs->begin("rs485rel", 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); } // ─── GET /api/state ────────────────────────────────────────────────────────── static void handleApiState() { String json = "{"; json += "\"ts\":" + String(gState->lastUpdateMs); json += ",\"err\":" + String(gState->commError ? "true" : "false"); json += ",\"ch\":["; for (uint8_t i = 0; i < gCfg->channelCount; i++) { if (i > 0) json += ","; json += gState->channels[i] ? "true" : "false"; } json += "],\"last\":\"" + gState->lastAction + "\""; json += "}"; gServer->sendHeader("Access-Control-Allow-Origin", "*"); gServer->send(200, "application/json", json); } // ─── GET /api/raw ──────────────────────────────────────────────────────────── 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(RelayState& state, RelayConfig& cfg, Preferences& prefs, ModbusClient& mb) { gState = &state; 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("/relay", HTTP_POST, handleRelay); gServer->on("/config", HTTP_GET, handleConfig); gServer->on("/config", HTTP_POST, handleConfigPost); gServer->on("/api/state", HTTP_GET, handleApiState); 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(); }