Files
oskarvitaliiandClaude Opus 5 0c06ba1032 feat(webhook): слать в status человеческую фразу вместо кода
В теле уведомления 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>
2026-08-08 01:33:48 +07:00

147 lines
9.0 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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>)====="