В теле уведомления status теперь содержит ту же формулировку, что видит
оператор в панели: «Насосы стоят, подачу держит резерв». Прежние
машиночитаемые значения переехали в новое поле state (ALARM / OK /
STARTED / STOPPED), чтобы сценарий на сервере ветвился по нему, а не
разбирал русский текст.
ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ: если сценарий в n8n сравнивал status с "ALARM",
условие надо переключить на state.
Формулировки собраны в одном месте прошивки (statusKind / statusWord /
statusSub / statusPhrase) и оттуда попадают и в webhook, и в панель через
новые поля k, w, s в /status. Раньше та же логика была продублирована в
JS панели; два набора фраз разошлись бы при первой правке. Побочно JS
сократился почти вдвое.
Прошито и проверено на приборе: /status отдаёт
{"k":"ok","w":"Подача есть","s":"работает насос 1"}, /config по-прежнему
отдаёт только имя хоста без токена.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
9.0 KiB
C
147 lines
9.0 KiB
C
#pragma once
|
||
|
||
// ============================================================
|
||
// ВЕБ-ПАНЕЛЬ
|
||
// ============================================================
|
||
// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё
|
||
// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой
|
||
// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча
|
||
// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных.
|
||
//
|
||
// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ
|
||
// («есть подача или нет») занимает верх страницы и читается с расстояния,
|
||
// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная
|
||
// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение,
|
||
// система ещё справляется), красная «авария» только при остановке основных.
|
||
//
|
||
// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние
|
||
// имена, и @import с Google Fonts там не грузится никогда. Характер задан
|
||
// контрастом кеглей, а не гарнитурой.
|
||
|
||
static const char PAGE_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="ru"><head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Насосная</title><link id="fav" rel="icon" href="data:,">
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{--bg:#0e1216;--sf:#161b21;--ln:#232a32;--ink:#dde3e9;--dim:#8b97a3;
|
||
--run:#35c46a;--warn:#f0a516;--flt:#e23c2e;--off:#2a323a}
|
||
body{background:var(--bg);color:var(--ink);min-height:100vh;padding:16px;
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
|
||
font-size:15px;line-height:1.4;display:flex;justify-content:center}
|
||
.wrap{width:100%;max-width:520px;display:flex;flex-direction:column;gap:10px}
|
||
.top{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
|
||
font-size:13px;color:var(--dim)}
|
||
.top b{font-weight:600;color:var(--ink);font-size:14px}
|
||
.ip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||
.st{border-radius:14px;padding:20px;border:1px solid var(--ln);background:var(--sf)}
|
||
.st b{display:block;font-size:clamp(28px,8.5vw,44px);line-height:1.05;
|
||
letter-spacing:-.02em;font-weight:800}
|
||
.st span{display:block;margin-top:6px;font-size:14px;color:var(--dim)}
|
||
.st.ok{background:rgba(53,196,106,.10);border-color:rgba(53,196,106,.35)}
|
||
.st.ok b{color:var(--run)}
|
||
.st.warn{background:rgba(240,165,22,.10);border-color:rgba(240,165,22,.4)}
|
||
.st.warn b{color:var(--warn)}
|
||
.st.flt{background:rgba(226,60,46,.12);border-color:rgba(226,60,46,.45);
|
||
animation:beat 1.6s ease-in-out infinite}
|
||
.st.flt b{color:var(--flt)}
|
||
@keyframes beat{50%{background:rgba(226,60,46,.26)}}
|
||
@media(prefers-reduced-motion:reduce){.st.flt{animation:none}}
|
||
.row{display:grid;gap:8px}
|
||
.r3{grid-template-columns:repeat(3,1fr)}
|
||
.r2{grid-template-columns:repeat(2,1fr)}
|
||
.c{background:var(--sf);border:1px solid var(--ln);border-radius:12px;padding:12px}
|
||
.c i{display:block;width:13px;height:13px;border-radius:50%;background:var(--off);
|
||
margin-bottom:10px;transition:background .2s}
|
||
.c.on i{background:var(--run);box-shadow:0 0 10px rgba(53,196,106,.7)}
|
||
.c.on.w i{background:var(--warn);box-shadow:0 0 10px rgba(240,165,22,.7)}
|
||
.c.on.f i{background:var(--flt);box-shadow:0 0 10px rgba(226,60,46,.7)}
|
||
.c em{font-style:normal;display:block;font-size:11px;letter-spacing:.08em;
|
||
text-transform:uppercase;color:var(--dim)}
|
||
.c b{display:block;margin-top:3px;font-size:14px;font-weight:600}
|
||
details{background:var(--sf);border:1px solid var(--ln);border-radius:12px}
|
||
summary{padding:12px 14px;cursor:pointer;font-size:14px;font-weight:600;
|
||
list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||
summary::-webkit-details-marker{display:none}
|
||
summary::after{content:"▾";color:var(--dim)}
|
||
details[open] summary::after{content:"▴"}
|
||
.body{padding:0 14px 14px;display:flex;flex-direction:column;gap:12px}
|
||
label{display:block;font-size:12px;color:var(--dim);margin-bottom:5px}
|
||
input{width:100%;padding:9px 10px;background:#0f1418;border:1px solid var(--ln);
|
||
border-radius:8px;color:var(--ink);font:inherit;font-size:14px}
|
||
input:focus{outline:2px solid rgba(53,196,106,.5);outline-offset:1px}
|
||
.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||
.hint{font-size:12px;color:var(--dim)}
|
||
button{width:100%;padding:11px;border-radius:8px;border:1px solid;font:inherit;
|
||
font-size:14px;font-weight:600;cursor:pointer}
|
||
.save{background:rgba(53,196,106,.14);border-color:rgba(53,196,106,.45);color:var(--run)}
|
||
.wifi{background:transparent;border-color:var(--ln);color:var(--dim);margin-top:2px}
|
||
.warnbox{background:rgba(226,60,46,.12);border:1px solid rgba(226,60,46,.4);
|
||
border-radius:8px;padding:10px;font-size:13px;color:#f2a49c}
|
||
.hide{display:none}
|
||
</style></head><body><div class="wrap">
|
||
|
||
<div class="top"><b id="dev">—</b><span class="ip" id="ip"></span></div>
|
||
|
||
<div class="st init" id="st"><b id="word">Связь…</b><span id="sub">получаем состояние прибора</span></div>
|
||
|
||
<div class="row r3">
|
||
<div class="c" id="l1"><i></i><em>Насос 1</em><b>—</b></div>
|
||
<div class="c" id="l2"><i></i><em>Насос 2</em><b>—</b></div>
|
||
<div class="c w" id="lE"><i></i><em>Резерв</em><b>—</b></div>
|
||
</div>
|
||
|
||
<div class="row r2">
|
||
<div class="c f" id="o1"><i></i><em>Выход «Авария»</em><b>—</b></div>
|
||
<div class="c w" id="o2"><i></i><em>Выход «Резерв»</em><b>—</b></div>
|
||
</div>
|
||
|
||
<details><summary>Настройки</summary><div class="body">
|
||
<div class="warnbox hide" id="fsw">Память недоступна — настройки не сохранятся до перезагрузки.</div>
|
||
<form method="POST" action="/set_config">
|
||
<div><label for="u">Адрес для уведомлений</label>
|
||
<input id="u" name="url" type="text" placeholder="оставьте пустым, чтобы не менять">
|
||
<div class="hint" id="uh" style="margin-top:5px"></div></div>
|
||
<div style="margin-top:12px"><label for="d">Имя прибора в уведомлениях</label>
|
||
<input id="d" name="device" maxlength="31">
|
||
<div class="hint" style="margin-top:5px">Латиница, цифры, дефис и подчёркивание</div></div>
|
||
<div style="margin-top:12px"><label>Задержка сигнала, секунд</label>
|
||
<div class="two">
|
||
<div><input id="a" name="alarm_delay" type="number" min="0" max="3600" aria-label="Задержка аварии">
|
||
<div class="hint" style="margin-top:5px">аварии</div></div>
|
||
<div><input id="e" name="emerg_delay" type="number" min="0" max="3600" aria-label="Задержка резерва">
|
||
<div class="hint" style="margin-top:5px">резерва</div></div></div></div>
|
||
<button class="save" type="submit" style="margin-top:14px">Сохранить</button>
|
||
</form>
|
||
<form method="POST" action="/reset_wifi"><button class="wifi" type="submit">Сменить сеть Wi-Fi</button></form>
|
||
</div></details>
|
||
|
||
</div><script>
|
||
var F={ok:"#35c46a",warn:"#f0a516",flt:"#e23c2e",init:"#8b97a3"};
|
||
function lamp(id,on,txt){var e=document.getElementById(id);
|
||
e.classList.toggle("on",!!on);e.querySelector("b").textContent=txt}
|
||
function fav(c){document.getElementById("fav").href="data:image/svg+xml,"+
|
||
encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='13' fill='"+c+"'/></svg>")}
|
||
var dev="";
|
||
function draw(d){
|
||
lamp("l1",d.p1,d.p1?"работает":"стоит");
|
||
lamp("l2",d.p2,d.p2?"работает":"стоит");
|
||
lamp("lE",d.pE,d.pE?"работает":"ожидание");
|
||
lamp("o1",d.rMain,d.rMain?"замкнут":"разомкнут");
|
||
lamp("o2",d.rEmg,d.rEmg?"замкнут":"разомкнут");
|
||
document.getElementById("st").className="st "+d.k;
|
||
document.getElementById("word").textContent=d.w;
|
||
document.getElementById("sub").textContent=d.s;
|
||
document.title=d.w+(dev?" · "+dev:"");
|
||
fav(F[d.k])}
|
||
function poll(){fetch("/status").then(function(r){return r.json()}).then(draw).catch(function(){})}
|
||
fetch("/config").then(function(r){return r.json()}).then(function(c){
|
||
dev=c.device;document.getElementById("dev").textContent=c.device;
|
||
document.getElementById("ip").textContent=c.ip;
|
||
document.getElementById("d").value=c.device;
|
||
document.getElementById("a").value=c.alarm;
|
||
document.getElementById("e").value=c.emerg;
|
||
document.getElementById("uh").textContent=c.host?"Сейчас: "+c.host:"Не задан — уведомления отключены";
|
||
if(!c.fs)document.getElementById("fsw").classList.remove("hide")}).catch(function(){});
|
||
poll();setInterval(poll,2000);
|
||
</script></body></html>)====="
|