Author SHA1 Message Date
cacto da0f941d79 Фикс вращения вентилятора + разделение сигналов на аналоговые/дискретные
transform-origin: center у SVG-группы лопастей считался от bounding box,
а не от геометрического центра ступицы (0,0) — вентилятор вращался вокруг
смещённой точки. Зафиксировано в 0 0.

Датчик больше не подписан как «DHT22» (сбивало с толку при добавлении
дискретных сигналов вроде door_open) — просто «Датчик», а сами сигналы
в карточке визуально разбиты на группы «Аналоговые»/«Дискретные».
2026-08-13 12:42:27 +05:00
cacto 073577e8d9 Реалистичная плата ESP32 с распиновкой + произвольные входные сигналы
Абстрактные кружки заменены на схему ESP32 DevKit с подписанными
GPIO-пинами и проводами к каждому периферийному устройству — видно,
что куда подключено. Датчик больше не ограничен фиксированными
температурой/влажностью: можно добавить любой именованный сигнал
(аналоговый слайдер или дискретный тумблер вкл/выкл) — бэкенд уже
принимал произвольный sensor_type, не хватало только формы.

Главный фикс: значения теперь публикуются в MQTT автоматически
(debounce ~350мс после слайдера, мгновенно для тумблера), а не только
по кнопке — раньше несохранённые локальные правки терялись при любом
переподключении SSE (сервер присылает эталон при каждом новом
сабскрайбе), из-за чего казалось, что слайдеры ни на что не влияют.
Плата и подписи обновляются локально сразу при вводе, без ожидания
ответа сервера.
2026-08-13 11:54:47 +05:00
+278 -66
View File
@@ -16,6 +16,10 @@
--accent-dim: #1f6b45; --accent-dim: #1f6b45;
--warn: #e8b339; --warn: #e8b339;
--pump: #3aa0e8; --pump: #3aa0e8;
--wire-sensor: #33d17a;
--wire-fan: #5b7ba8;
--wire-light: #e8b339;
--wire-pump: #3aa0e8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -26,7 +30,7 @@
padding: 24px; padding: 24px;
} }
h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; } h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; }
.subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; } .subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 640px; }
.conn { .conn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
font-size: 0.8rem; color: var(--muted); margin-bottom: 20px; font-size: 0.8rem; color: var(--muted); margin-bottom: 20px;
@@ -36,10 +40,19 @@
.conn.offline .dot { background: #d95555; } .conn.offline .dot { background: #d95555; }
.board-wrap { display: flex; justify-content: center; margin-bottom: 28px; } .board-wrap { display: flex; justify-content: center; margin-bottom: 28px; }
.board { width: 100%; max-width: 520px; } .board { width: 100%; max-width: 720px; }
.board rect.case { fill: var(--panel); stroke: var(--border); stroke-width: 2; } .pin { fill: #cbd5e1; }
.pin-label { font-size: 10px; fill: var(--muted); }
.wire { fill: none; stroke-width: 2; opacity: 0.85; }
.peripheral-label { font-size: 12px; fill: var(--text); font-weight: 600; text-anchor: middle; }
.peripheral-sub { font-size: 9px; fill: var(--muted); text-anchor: middle; }
.fan-blades { transform-origin: center; transition: opacity .2s; } /* The blade ellipses are laid out so their geometric centroid is the
group's local (0,0) — but CSS "transform-origin: center" for SVG uses
the bounding box center, which isn't the same point (the ellipses'
144x28-ish extents aren't symmetric around 0,0), so it wobbled around
an off-center point instead of spinning cleanly. Pin it to 0 0. */
.fan-blades { transform-origin: 0px 0px; transition: opacity .2s; }
.fan-blades.on { animation: spin 0.9s linear infinite; } .fan-blades.on { animation: spin 0.9s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@@ -53,9 +66,9 @@
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px; gap: 16px;
max-width: 1080px; max-width: 1160px;
margin: 0 auto; margin: 0 auto;
} }
.card { .card {
@@ -63,7 +76,9 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
transition: box-shadow .3s, border-color .3s;
} }
.card.flash { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(51,209,122,0.25); }
.card h2 { .card h2 {
font-size: 0.95rem; margin: 0 0 12px; font-size: 0.95rem; margin: 0 0 12px;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@@ -81,7 +96,7 @@
font-size: 0.88rem; font-size: 0.88rem;
} }
input[type=range] { width: 100%; } input[type=range] { width: 100%; }
.row { display: flex; gap: 10px; } .row { display: flex; gap: 10px; align-items: flex-end; }
.row > div { flex: 1; } .row > div { flex: 1; }
button { button {
@@ -95,29 +110,98 @@
.status-line { display: flex; justify-content: space-between; font-size: 0.85rem; margin-top: 8px; } .status-line { display: flex; justify-content: space-between; font-size: 0.85rem; margin-top: 8px; }
.status-line span:last-child { color: var(--text); font-weight: 600; } .status-line span:last-child { color: var(--text); font-weight: 600; }
.value { color: var(--text); font-weight: 600; } .value { color: var(--text); font-weight: 600; }
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; }
.signal-group-label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); margin-top: 12px; margin-bottom: 2px;
}
.signal-group-label:first-child { margin-top: 0; }
.signal-row {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.85rem;
}
.signal-row:first-of-type { border-top: none; }
.signal-row .name { color: var(--muted); }
.signal-row input[type=range] { flex: 1; }
.signal-row .val { width: 44px; text-align: right; font-weight: 600; }
.toggle {
width: 40px; height: 22px; border-radius: 999px; background: var(--panel-2);
border: 1px solid var(--border); position: relative; cursor: pointer; flex-shrink: 0;
}
.toggle.on { background: var(--accent-dim); border-color: var(--accent); }
.toggle .knob {
position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
background: var(--muted); transition: left .15s, background .15s;
}
.toggle.on .knob { left: 20px; background: var(--accent); }
.add-signal { border-top: 1px dashed var(--border); margin-top: 12px; padding-top: 12px; }
.add-signal .row2 { display: flex; gap: 8px; margin-top: 6px; }
select {
padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--text); font-size: 0.85rem;
}
</style> </style>
</head> </head>
<body> <body>
<h1>ESP32 Emulator</h1> <h1>ESP32 Emulator</h1>
<p class="subtitle">Виртуальный контроллер гроубокса — датчик + вентилятор + свет + помпа, говорит по тому же MQTT-протоколу, что и настоящее устройство.</p> <p class="subtitle">Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания датчика вы задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда).</p>
<div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div> <div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div>
<div class="board-wrap"> <div class="board-wrap">
<svg class="board" viewBox="0 0 400 220" xmlns="http://www.w3.org/2000/svg"> <svg class="board" viewBox="0 0 700 340" xmlns="http://www.w3.org/2000/svg">
<rect class="case" x="10" y="10" width="380" height="200" rx="14"/> <!-- ESP32 devkit board -->
<g transform="translate(255,20)">
<rect x="0" y="0" width="190" height="300" rx="8" fill="#1a4d2e" stroke="#0d2e1b" stroke-width="2"/>
<rect x="55" y="30" width="80" height="80" rx="4" fill="#4a4a4a" stroke="#2a2a2a" stroke-width="1.5"/>
<text x="95" y="65" text-anchor="middle" font-size="11" fill="#ddd" font-weight="700">ESP32</text>
<text x="95" y="80" text-anchor="middle" font-size="8" fill="#aaa">WROOM-32</text>
<rect x="75" y="270" width="40" height="22" rx="2" fill="#c0c0c0" stroke="#888" stroke-width="1"/>
<text x="95" y="285" text-anchor="middle" font-size="7" fill="#333">USB</text>
<circle cx="20" cy="130" r="6" fill="#333" stroke="#666"/>
<text x="20" y="145" text-anchor="middle" font-size="7" fill="#888">EN</text>
<circle cx="170" cy="130" r="6" fill="#333" stroke="#666"/>
<text x="170" y="145" text-anchor="middle" font-size="7" fill="#888">BOOT</text>
<!-- sensor --> <!-- left pin header -->
<g transform="translate(60,60)"> <g id="pin-sensor"><circle class="pin" cx="0" cy="150" r="3.5"/></g>
<circle r="30" fill="#233348" stroke="#37527a" stroke-width="2"/> <!-- right pin headers -->
<text id="sensor-temp" x="0" y="-2" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="600">24°C</text> <g id="pin-fan"><circle class="pin" cx="190" cy="130" r="3.5"/></g>
<text id="sensor-humidity" x="0" y="14" text-anchor="middle" font-size="10" fill="#8ea0b8">55%</text> <g id="pin-light"><circle class="pin" cx="190" cy="170" r="3.5"/></g>
<g id="pin-pump"><circle class="pin" cx="190" cy="210" r="3.5"/></g>
<!-- decorative header pins -->
<g fill="#8a8a8a">
<circle cx="0" cy="20" r="2.5"/><circle cx="0" cy="45" r="2.5"/><circle cx="0" cy="70" r="2.5"/>
<circle cx="0" cy="95" r="2.5"/><circle cx="0" cy="120" r="2.5"/><circle cx="0" cy="175" r="2.5"/>
<circle cx="0" cy="200" r="2.5"/><circle cx="0" cy="225" r="2.5"/><circle cx="0" cy="250" r="2.5"/>
<circle cx="190" cy="20" r="2.5"/><circle cx="190" cy="45" r="2.5"/><circle cx="190" cy="70" r="2.5"/>
<circle cx="190" cy="95" r="2.5"/><circle cx="190" cy="150" r="2.5"/><circle cx="190" cy="190" r="2.5"/>
<circle cx="190" cy="230" r="2.5"/><circle cx="190" cy="250" r="2.5"/><circle cx="190" cy="270" r="2.5"/>
</g>
<text class="pin-label" x="8" y="153">GPIO4</text>
<text class="pin-label" x="150" y="127">GPIO16</text>
<text class="pin-label" x="150" y="167">GPIO17</text>
<text class="pin-label" x="150" y="207">GPIO18</text>
</g> </g>
<text x="60" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">датчик</text>
<!-- fan --> <!-- sensor (left) -->
<g transform="translate(160,60)"> <path class="wire" d="M 255,170 C 180,170 140,60 90,60" stroke="var(--wire-sensor)"/>
<circle r="30" fill="#233348" stroke="#37527a" stroke-width="2"/> <g transform="translate(70,60)">
<circle r="34" fill="#233348" stroke="var(--wire-sensor)" stroke-width="2"/>
<text id="board-sensor-primary" x="0" y="-2" text-anchor="middle" font-size="14" fill="#e6edf5" font-weight="700">24°C</text>
<text id="board-sensor-secondary" x="0" y="14" text-anchor="middle" font-size="10" fill="#8ea0b8">55%</text>
</g>
<text class="peripheral-label" x="70" y="118">Датчик</text>
<text class="peripheral-sub" x="70" y="130">GPIO4 · input</text>
<!-- fan (top right) -->
<path class="wire" d="M 445,150 C 520,150 550,80 600,70" stroke="var(--wire-fan)"/>
<g transform="translate(615,68)">
<circle r="30" fill="#233348" stroke="var(--wire-fan)" stroke-width="2"/>
<g id="fan-blades" class="fan-blades"> <g id="fan-blades" class="fan-blades">
<ellipse cx="0" cy="-12" rx="6" ry="14" fill="#5b7ba8"/> <ellipse cx="0" cy="-12" rx="6" ry="14" fill="#5b7ba8"/>
<ellipse cx="12" cy="6" rx="6" ry="14" fill="#5b7ba8" transform="rotate(120 12 6)"/> <ellipse cx="12" cy="6" rx="6" ry="14" fill="#5b7ba8" transform="rotate(120 12 6)"/>
@@ -125,22 +209,27 @@
<circle r="4" fill="#e6edf5"/> <circle r="4" fill="#e6edf5"/>
</g> </g>
</g> </g>
<text x="160" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">вентилятор</text> <text class="peripheral-label" x="615" y="118">Вентилятор</text>
<text class="peripheral-sub" x="615" y="130">GPIO16 · relay</text>
<!-- light --> <!-- light (mid right) -->
<g transform="translate(260,60)"> <path class="wire" d="M 445,190 C 520,190 560,178 600,178" stroke="var(--wire-light)"/>
<g transform="translate(615,178)">
<circle id="bulb-glow" class="bulb-glow" r="26" fill="#e8b339" opacity="0"/> <circle id="bulb-glow" class="bulb-glow" r="26" fill="#e8b339" opacity="0"/>
<circle id="bulb-body" class="bulb-body" r="16" fill="#233348" stroke="#37527a" stroke-width="2"/> <circle id="bulb-body" class="bulb-body" r="16" fill="#233348" stroke="var(--wire-light)" stroke-width="2"/>
</g> </g>
<text x="260" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">свет</text> <text class="peripheral-label" x="615" y="220">Свет</text>
<text class="peripheral-sub" x="615" y="232">GPIO17 · relay</text>
<!-- pump --> <!-- pump (bottom right) -->
<g transform="translate(340,60)"> <path class="wire" d="M 445,230 C 520,230 550,285 600,290" stroke="var(--wire-pump)"/>
<circle id="pump-ring" class="pump-ring" r="26" fill="none" stroke="#3aa0e8" stroke-width="3" opacity="0"/> <g transform="translate(615,292)">
<circle r="16" fill="#233348" stroke="#37527a" stroke-width="2"/> <circle id="pump-ring" class="pump-ring" r="26" fill="none" stroke="var(--wire-pump)" stroke-width="3" opacity="0"/>
<path d="M0,-8 C6,-2 6,6 0,8 C-6,6 -6,-2 0,-8 Z" fill="#3aa0e8"/> <circle r="16" fill="#233348" stroke="var(--wire-pump)" stroke-width="2"/>
<path d="M0,-8 C6,-2 6,6 0,8 C-6,6 -6,-2 0,-8 Z" fill="var(--wire-pump)"/>
</g> </g>
<text x="340" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">помпа</text> <text class="peripheral-label" x="615" y="332">Помпа</text>
<text class="peripheral-sub" x="500" y="332">GPIO18 · relay</text>
</svg> </svg>
</div> </div>
@@ -148,13 +237,21 @@
<script> <script>
const SLOTS = { const SLOTS = {
sensor: { label: 'Датчик температуры/влажности', kind: 'sensor' }, sensor: { label: 'Датчик (входные сигналы)', kind: 'sensor' },
fan: { label: 'Вентилятор', kind: 'actuator' }, fan: { label: 'Вентилятор', kind: 'actuator' },
light: { label: 'Свет', kind: 'actuator' }, light: { label: 'Свет', kind: 'actuator' },
pump: { label: 'Помпа', kind: 'actuator' }, pump: { label: 'Помпа', kind: 'actuator' },
}; };
// Signals with a slider preset (min/max/step/unit); anything else added via
// "add signal" is either a plain number or a discrete on/off toggle.
const SIGNAL_PRESETS = {
temperature: { min: -10, max: 50, step: 0.5, unit: '°C' },
humidity: { min: 0, max: 100, step: 1, unit: '%' },
};
let latest = {}; let latest = {};
let knownSignals = {}; // slot -> Set of sensor_type names currently rendered
function cardHTML(slot) { function cardHTML(slot) {
const meta = SLOTS[slot]; const meta = SLOTS[slot];
@@ -169,11 +266,20 @@ function cardHTML(slot) {
if (meta.kind === 'sensor') { if (meta.kind === 'sensor') {
body += ` body += `
<label>Температура: <span class="value" id="${idPrefix}-temp-value">24</span> °C</label> <div id="${idPrefix}-signals"></div>
<input type="range" id="${idPrefix}-temp" min="-10" max="50" step="0.5" value="24"> <div class="add-signal">
<label>Влажность: <span class="value" id="${idPrefix}-humidity-value">55</span> %</label> <label>Добавить сигнал</label>
<input type="range" id="${idPrefix}-humidity" min="0" max="100" step="1" value="55"> <div class="row2">
<button onclick="publishTelemetry('${slot}')">Опубликовать показания</button> <input type="text" id="${idPrefix}-new-name" placeholder="напр. door_open">
<select id="${idPrefix}-new-kind">
<option value="analog">аналоговый</option>
<option value="discrete">дискретный (вкл/выкл)</option>
</select>
</div>
<button class="secondary" onclick="addSignal('${slot}')">Добавить</button>
</div>
<p class="hint">Входные сигналы публикуются в MQTT автоматически (слайдер — с небольшой задержкой после того, как отпустили; переключатель — сразу).</p>
<button class="secondary" onclick="publishAll('${slot}')">Переопубликовать всё сейчас</button>
`; `;
} else { } else {
body += ` body += `
@@ -181,14 +287,12 @@ function cardHTML(slot) {
<div class="status-line" id="${idPrefix}-level-row" style="display:none"> <div class="status-line" id="${idPrefix}-level-row" style="display:none">
<span>Уровень</span><span id="${idPrefix}-level">—</span> <span>Уровень</span><span id="${idPrefix}-level">—</span>
</div> </div>
<p style="color:var(--muted); font-size:0.78rem; margin-top:10px;"> <p class="hint">Выходной сигнал — меняется, когда платформа присылает команду по MQTT. Ручного переключателя здесь нет специально.</p>
Управляется командами от платформы — здесь только отображение.
</p>
`; `;
} }
const badge = meta.kind === 'actuator' ? `<span class="badge" id="${idPrefix}-badge">выключено</span>` : ''; const badge = meta.kind === 'actuator' ? `<span class="badge" id="${idPrefix}-badge">выключено</span>` : '';
return `<div class="card"> return `<div class="card" id="${idPrefix}-card">
<h2>${meta.label} ${badge}</h2> <h2>${meta.label} ${badge}</h2>
${body} ${body}
</div>`; </div>`;
@@ -196,22 +300,130 @@ function cardHTML(slot) {
document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join(''); document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join('');
function isDiscreteSignal(slot, name) {
return knownSignals[slot]?.discrete?.has(name);
}
function analogRowHTML(slot, name, value) {
const preset = SIGNAL_PRESETS[name];
const min = preset?.min ?? 0, max = preset?.max ?? 100, step = preset?.step ?? 1, unit = preset?.unit ?? '';
return `<div class="signal-row" data-signal="${name}">
<span class="name">${name}${unit ? ' ('+unit+')' : ''}</span>
<input type="range" min="${min}" max="${max}" step="${step}" value="${value}"
oninput="onSliderInput('${slot}','${name}', this.value)">
<span class="val" id="${slot}-${cssId(name)}-val">${value}</span>
</div>`;
}
function discreteRowHTML(slot, name, value) {
return `<div class="signal-row" data-signal="${name}">
<span class="name">${name}</span>
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${name}')"><div class="knob"></div></div>
</div>`;
}
function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); }
// Discrete vs analog are shown as two visually separate groups — a
// discrete "door_open" signal is still just another sensor_type on the
// same physical device underneath (one MQTT device can report several
// sensor_types, same as real telemetry), but grouping keeps it from
// reading as "attached to the temperature sensor".
function renderSignals(slot, readings) {
const container = document.getElementById(`${slot}-signals`);
knownSignals[slot] = knownSignals[slot] || { discrete: new Set() };
const analogNames = orderedNames(readings).filter((n) => !isDiscreteSignal(slot, n));
const discreteNames = Object.keys(readings).filter((n) => isDiscreteSignal(slot, n)).sort();
let html = '';
if (analogNames.length) {
html += `<div class="signal-group-label">Аналоговые</div>`;
html += analogNames.map((name) => analogRowHTML(slot, name, readings[name])).join('');
}
if (discreteNames.length) {
html += `<div class="signal-group-label">Дискретные</div>`;
html += discreteNames.map((name) => discreteRowHTML(slot, name, readings[name])).join('');
}
container.innerHTML = html;
updateBoardText(slot, readings);
}
// Known presets sort first (temperature before humidity), custom signals
// after, alphabetically — keeps the board's two-line readout predictable.
function orderedNames(readings) {
const names = Object.keys(readings);
const rank = (n) => (n === 'temperature' ? 0 : n === 'humidity' ? 1 : 2);
return names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
}
function updateBoardText(slot, readings) {
if (slot !== 'sensor') return;
const [primary, secondary] = orderedNames(readings);
const fmt = (n) => n === undefined ? '' : `${readings[n]}${SIGNAL_PRESETS[n]?.unit ?? ''}`;
document.getElementById('board-sensor-primary').textContent = fmt(primary) || '—';
document.getElementById('board-sensor-secondary').textContent = secondary ? fmt(secondary) : '';
}
// Debounced per-signal auto-publish: a value only "counts" as sent to the
// platform ~350ms after the user stops moving the slider, so a full drag
// doesn't flood MQTT with one message per pixel — but it still leaves the
// server (and thus the SSE truth every client, including reconnects,
// converges to) in sync within a fraction of a second, not only when a
// separate "publish" button is remembered/clicked.
const publishTimers = {};
function schedulePublish(slot, name, value) {
const key = `${slot}:${name}`;
clearTimeout(publishTimers[key]);
publishTimers[key] = setTimeout(() => publishOne(slot, name, value), 350);
}
function onSliderInput(slot, name, value) {
const el = document.getElementById(`${slot}-${cssId(name)}-val`);
if (el) el.textContent = value;
if (!latest[slot]) latest[slot] = { readings: {} };
latest[slot].readings[name] = parseFloat(value);
updateBoardText(slot, latest[slot].readings);
schedulePublish(slot, name, value);
}
function toggleDiscrete(slot, name) {
const cur = latest[slot]?.readings?.[name] ?? 0;
const next = cur ? 0 : 1;
latest[slot].readings[name] = next;
renderSignals(slot, latest[slot].readings);
publishOne(slot, name, next); // discrete flips are instant, no debounce needed
}
function addSignal(slot) {
const nameInput = document.getElementById(`${slot}-new-name`);
const kindSelect = document.getElementById(`${slot}-new-kind`);
const name = nameInput.value.trim();
if (!name) return;
const isDiscrete = kindSelect.value === 'discrete';
knownSignals[slot] = knownSignals[slot] || { discrete: new Set() };
if (isDiscrete) knownSignals[slot].discrete.add(name);
if (!latest[slot]) latest[slot] = { readings: {} };
latest[slot].readings[name] = 0;
renderSignals(slot, latest[slot].readings);
nameInput.value = '';
publishOne(slot, name, 0); // publish immediately so it survives an SSE reconnect right away
}
function render(snapshot) { function render(snapshot) {
latest = {};
for (const d of snapshot.devices) { for (const d of snapshot.devices) {
const prevPower = latest[d.slot]?.power;
const prevLevel = latest[d.slot]?.level;
latest[d.slot] = d; latest[d.slot] = d;
document.getElementById(`${d.slot}-external-id`).value = d.external_id; document.getElementById(`${d.slot}-external-id`).value = d.external_id;
document.getElementById(`${d.slot}-zone-id`).value = d.zone_id; document.getElementById(`${d.slot}-zone-id`).value = d.zone_id;
if (d.kind === 'sensor') { if (d.kind === 'sensor') {
const temp = d.readings?.temperature ?? 0; renderSignals(d.slot, d.readings || {});
const humidity = d.readings?.humidity ?? 0;
document.getElementById(`${d.slot}-temp`).value = temp;
document.getElementById(`${d.slot}-temp-value`).textContent = temp;
document.getElementById(`${d.slot}-humidity`).value = humidity;
document.getElementById(`${d.slot}-humidity-value`).textContent = humidity;
document.getElementById('sensor-temp').textContent = `${temp}°C`;
document.getElementById('sensor-humidity').textContent = `${humidity}%`;
} else { } else {
const badge = document.getElementById(`${d.slot}-badge`); const badge = document.getElementById(`${d.slot}-badge`);
badge.textContent = d.power ? 'включено' : 'выключено'; badge.textContent = d.power ? 'включено' : 'выключено';
@@ -222,6 +434,10 @@ function render(snapshot) {
document.getElementById(`${d.slot}-level-row`).style.display = 'flex'; document.getElementById(`${d.slot}-level-row`).style.display = 'flex';
document.getElementById(`${d.slot}-level`).textContent = d.level; document.getElementById(`${d.slot}-level`).textContent = d.level;
} }
if (prevPower !== undefined && (prevPower !== d.power || prevLevel !== d.level)) {
flashCard(d.slot);
}
} }
} }
@@ -231,14 +447,10 @@ function render(snapshot) {
document.getElementById('pump-ring').classList.toggle('on', !!latest.pump?.power); document.getElementById('pump-ring').classList.toggle('on', !!latest.pump?.power);
} }
for (const slot of Object.keys(SLOTS)) { function flashCard(slot) {
if (SLOTS[slot].kind !== 'sensor') continue; const card = document.getElementById(`${slot}-card`);
document.getElementById(`${slot}-temp`).addEventListener('input', (e) => { card.classList.add('flash');
document.getElementById(`${slot}-temp-value`).textContent = e.target.value; setTimeout(() => card.classList.remove('flash'), 900);
});
document.getElementById(`${slot}-humidity`).addEventListener('input', (e) => {
document.getElementById(`${slot}-humidity-value`).textContent = e.target.value;
});
} }
async function saveIdentity(slot) { async function saveIdentity(slot) {
@@ -251,21 +463,21 @@ async function saveIdentity(slot) {
}); });
} }
async function publishTelemetry(slot) { async function publishOne(slot, sensorType, value) {
const temp = parseFloat(document.getElementById(`${slot}-temp`).value);
const humidity = parseFloat(document.getElementById(`${slot}-humidity`).value);
await fetch(`/api/devices/${slot}/telemetry`, { await fetch(`/api/devices/${slot}/telemetry`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sensor_type: 'temperature', value: temp }), body: JSON.stringify({ sensor_type: sensorType, value: parseFloat(value) }),
});
await fetch(`/api/devices/${slot}/telemetry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sensor_type: 'humidity', value: humidity }),
}); });
} }
async function publishAll(slot) {
const readings = latest[slot]?.readings || {};
for (const [name, value] of Object.entries(readings)) {
await publishOne(slot, name, value);
}
}
function connectEvents() { function connectEvents() {
const es = new EventSource('/api/events'); const es = new EventSource('/api/events');
const conn = document.getElementById('conn'); const conn = document.getElementById('conn');