Раньше "добавить сигнал" дописывал ещё один sensor_type к sensor-1 —
концептуально неверно и для дискретных входов, и тем более для выходов.
Теперь и вход (датчик), и выход (актуатор) создаются как полноценное
отдельное MQTT-устройство со своим external_id/zone_id — так же, как
завели бы новое Device на платформе.
state.Store обобщён: 4 фиксированных слота (плата, GPIO-пины, нельзя
удалить) + произвольное число доп. устройств (свой slot "extra-N",
можно удалить). HTTP: POST /api/devices (создать, kind=sensor|actuator),
DELETE /api/devices/{slot}.
На схеме доп. устройства подвешены к пину EXT пунктирным проводом —
визуально отделены от жёстко распаянных GPIO4/16/17/18. Можно указать
и произвольный пин (просто текстовая метка на схеме, без валидации) —
подписывается у провода вместо общего EXT.
Карточки создаются/удаляются динамически при получении снапшота через
SSE, без перезагрузки страницы.
622 lines
27 KiB
HTML
622 lines
27 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ESP32 Emulator — гроубокс</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1720;
|
|
--panel: #182333;
|
|
--panel-2: #1f2d42;
|
|
--border: #2b3b52;
|
|
--text: #e6edf5;
|
|
--muted: #8ea0b8;
|
|
--accent: #33d17a;
|
|
--accent-dim: #1f6b45;
|
|
--warn: #e8b339;
|
|
--pump: #3aa0e8;
|
|
--wire-sensor: #33d17a;
|
|
--wire-fan: #5b7ba8;
|
|
--wire-light: #e8b339;
|
|
--wire-pump: #3aa0e8;
|
|
--wire-ext: #7a8ba8;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
padding: 24px;
|
|
}
|
|
h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; }
|
|
.subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 680px; }
|
|
.conn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
font-size: 0.8rem; color: var(--muted); margin-bottom: 20px;
|
|
}
|
|
.conn .dot { width: 8px; height: 8px; border-radius: 50%; background: #666; }
|
|
.conn.online .dot { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
|
.conn.offline .dot { background: #d95555; }
|
|
|
|
.board-wrap { display: flex; justify-content: center; margin-bottom: 28px; }
|
|
.board { width: 100%; max-width: 720px; }
|
|
.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; }
|
|
|
|
/* 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; }
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
|
|
.bulb-glow { opacity: 0; transition: opacity .25s; }
|
|
.bulb-glow.on { opacity: 1; }
|
|
.bulb-body { transition: fill .25s; }
|
|
|
|
.pump-ring { opacity: 0; transition: opacity .25s; }
|
|
.pump-ring.on { opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
|
|
@keyframes pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 16px;
|
|
max-width: 1160px;
|
|
margin: 0 auto;
|
|
}
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
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.add-device-card { border-style: dashed; }
|
|
.card h2 {
|
|
font-size: 0.95rem; margin: 0 0 12px;
|
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
}
|
|
.card h2 .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.badge {
|
|
font-size: 0.72rem; padding: 2px 8px; border-radius: 999px;
|
|
background: var(--panel-2); color: var(--muted); font-weight: 500; flex-shrink: 0;
|
|
}
|
|
.badge.on { background: var(--accent-dim); color: var(--accent); }
|
|
.badge.extra { background: #2a2440; color: #b39ddb; }
|
|
|
|
label { display: block; font-size: 0.78rem; color: var(--muted); margin: 10px 0 4px; }
|
|
input[type=text], input[type=number] {
|
|
width: 100%; padding: 7px 9px; border-radius: 7px;
|
|
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
|
font-size: 0.88rem;
|
|
}
|
|
input[type=range] { width: 100%; }
|
|
.row { display: flex; gap: 10px; align-items: flex-end; }
|
|
.row > div { flex: 1; }
|
|
|
|
button {
|
|
margin-top: 12px; width: 100%; padding: 8px 10px;
|
|
border: none; border-radius: 7px; background: var(--accent); color: #05170d;
|
|
font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
|
}
|
|
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
|
button.danger { background: transparent; color: #e08a8a; border: 1px solid #4a2c2c; }
|
|
button:active { transform: translateY(1px); }
|
|
|
|
.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; }
|
|
.value { color: var(--text); font-weight: 600; }
|
|
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; }
|
|
|
|
.signal-row {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 8px; padding: 6px 0; font-size: 0.85rem;
|
|
}
|
|
.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); }
|
|
|
|
select {
|
|
width: 100%; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border);
|
|
background: var(--panel-2); color: var(--text); font-size: 0.85rem;
|
|
}
|
|
.field-row { display: flex; gap: 8px; }
|
|
.field-row > div { flex: 1; }
|
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 0.85rem; }
|
|
.checkbox-row input { width: auto; margin: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>ESP32 Emulator</h1>
|
|
<p class="subtitle">Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда). Дополнительные сигналы — это отдельные устройства (свой external_id), а не довесок к существующим.</p>
|
|
<div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div>
|
|
|
|
<div class="board-wrap">
|
|
<svg class="board" viewBox="0 0 700 460" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- 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>
|
|
|
|
<!-- left pin header -->
|
|
<g id="pin-sensor"><circle class="pin" cx="0" cy="150" r="3.5"/></g>
|
|
<!-- right pin headers -->
|
|
<g id="pin-fan"><circle class="pin" cx="190" cy="130" r="3.5"/></g>
|
|
<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>
|
|
<!-- expansion header (I2C/GPIO for extra devices, not one fixed pin) -->
|
|
<g id="pin-ext"><circle class="pin" cx="95" cy="300" 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>
|
|
<text class="pin-label" x="60" y="298" text-anchor="end">EXT</text>
|
|
</g>
|
|
|
|
<!-- sensor (left) -->
|
|
<path class="wire" d="M 255,170 C 180,170 140,60 90,60" stroke="var(--wire-sensor)"/>
|
|
<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">
|
|
<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)"/>
|
|
<circle r="4" fill="#e6edf5"/>
|
|
</g>
|
|
</g>
|
|
<text class="peripheral-label" x="615" y="118">Вентилятор</text>
|
|
<text class="peripheral-sub" x="615" y="130">GPIO16 · relay</text>
|
|
|
|
<!-- light (mid right) -->
|
|
<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-body" class="bulb-body" r="16" fill="#233348" stroke="var(--wire-light)" stroke-width="2"/>
|
|
</g>
|
|
<text class="peripheral-label" x="615" y="220">Свет</text>
|
|
<text class="peripheral-sub" x="615" y="232">GPIO17 · relay</text>
|
|
|
|
<!-- pump (bottom right) -->
|
|
<path class="wire" d="M 445,230 C 520,230 550,285 600,290" stroke="var(--wire-pump)"/>
|
|
<g transform="translate(615,292)">
|
|
<circle id="pump-ring" class="pump-ring" r="26" fill="none" stroke="var(--wire-pump)" stroke-width="3" opacity="0"/>
|
|
<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>
|
|
<text class="peripheral-label" x="615" y="332">Помпа</text>
|
|
<text class="peripheral-sub" x="500" y="332">GPIO18 · relay</text>
|
|
|
|
<!-- extra devices attach here, below the fixed board — see renderExtensionDevices() -->
|
|
<path class="wire" id="ext-trunk" d="M 350,320 L 350,360" stroke="var(--wire-ext)" stroke-dasharray="4 3"/>
|
|
<text x="350" y="378" text-anchor="middle" font-size="9" fill="var(--wire-ext)">доп. устройства (EXT)</text>
|
|
<g id="extension-devices"></g>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="grid" id="cards"></div>
|
|
|
|
<script>
|
|
// Presets give known signal names a friendly slider range/unit; anything
|
|
// else is just min 0 / max 100 unless the user picked "discrete".
|
|
const SIGNAL_PRESETS = {
|
|
temperature: { min: -10, max: 50, step: 0.5, unit: '°C' },
|
|
humidity: { min: 0, max: 100, step: 1, unit: '%' },
|
|
};
|
|
|
|
const CORE_LABELS = { sensor: 'Датчик', fan: 'Вентилятор', light: 'Свет', pump: 'Помпа' };
|
|
|
|
let latest = {}; // slot -> last known Device
|
|
let cardSlots = new Set(); // slots that currently have a rendered card
|
|
|
|
function escapeHTML(str) {
|
|
return String(str).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
}
|
|
|
|
function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
|
|
|
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="${escapeHTML(name)}">
|
|
<span class="name">${escapeHTML(name)}${unit ? ' ('+unit+')' : ''}</span>
|
|
<input type="range" min="${min}" max="${max}" step="${step}" value="${value}"
|
|
oninput="onSliderInput('${slot}','${escapeHTML(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="${escapeHTML(name)}">
|
|
<span class="name">${escapeHTML(name)}</span>
|
|
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${escapeHTML(name)}')"><div class="knob"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 deviceTitle(d) {
|
|
if (d.core) return CORE_LABELS[d.slot];
|
|
if (d.kind === 'sensor') return Object.keys(d.readings || {})[0] || d.external_id;
|
|
return d.external_id;
|
|
}
|
|
|
|
function cardHTML(d) {
|
|
const badge = d.kind === 'actuator'
|
|
? `<span class="badge" id="${d.slot}-badge">выключено</span>`
|
|
: (!d.core ? `<span class="badge extra">доп.</span>` : '');
|
|
|
|
let body = '';
|
|
if (d.pin) {
|
|
body += `<p class="hint" style="margin-top:0">Пин: <strong style="color:var(--text)">${escapeHTML(d.pin)}</strong></p>`;
|
|
}
|
|
body += `
|
|
<label>external_id</label>
|
|
<input type="text" id="${d.slot}-external-id">
|
|
<label>zone_id</label>
|
|
<input type="text" id="${d.slot}-zone-id">
|
|
<button class="secondary" onclick="saveIdentity('${d.slot}')">Сохранить идентификатор</button>
|
|
`;
|
|
|
|
if (d.kind === 'sensor') {
|
|
body += `<div id="${d.slot}-signals"></div>`;
|
|
body += `<p class="hint">Публикуется в MQTT автоматически (слайдер — с небольшой задержкой после того, как отпустили; переключатель — сразу).</p>`;
|
|
} else {
|
|
body += `
|
|
<div class="status-line"><span>Питание</span><span id="${d.slot}-power">—</span></div>
|
|
<div class="status-line" id="${d.slot}-level-row" style="display:none">
|
|
<span>Уровень</span><span id="${d.slot}-level">—</span>
|
|
</div>
|
|
<p class="hint">Выходной сигнал — меняется, когда платформа присылает команду по MQTT. Ручного переключателя здесь нет специально.</p>
|
|
`;
|
|
}
|
|
|
|
if (!d.core) {
|
|
body += `<button class="danger" onclick="removeDevice('${d.slot}')">Удалить устройство</button>`;
|
|
}
|
|
|
|
return `<div class="card" id="${d.slot}-card">
|
|
<h2><span class="title">${escapeHTML(deviceTitle(d))}</span> ${badge}</h2>
|
|
${body}
|
|
</div>`;
|
|
}
|
|
|
|
function addDeviceCardHTML() {
|
|
return `<div class="card add-device-card" id="add-device-card">
|
|
<h2><span class="title">Добавить устройство</span></h2>
|
|
<label>Тип</label>
|
|
<select id="new-device-kind" onchange="onNewDeviceKindChange()">
|
|
<option value="sensor">Вход (датчик)</option>
|
|
<option value="actuator">Выход (актуатор)</option>
|
|
</select>
|
|
|
|
<div id="new-device-sensor-fields">
|
|
<label>Название сигнала</label>
|
|
<input type="text" id="new-device-signal-name" placeholder="напр. door_open">
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="new-device-discrete">
|
|
<label style="margin:0" for="new-device-discrete">Дискретный (вкл/выкл)</label>
|
|
</div>
|
|
</div>
|
|
<div id="new-device-actuator-fields" style="display:none">
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="new-device-supports-level">
|
|
<label style="margin:0" for="new-device-supports-level">Поддерживает уровень (set_level)</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-row">
|
|
<div>
|
|
<label>external_id</label>
|
|
<input type="text" id="new-device-external-id" placeholder="напр. door-1">
|
|
</div>
|
|
<div>
|
|
<label>zone_id</label>
|
|
<input type="text" id="new-device-zone-id" value="1">
|
|
</div>
|
|
</div>
|
|
<label>Пин (необязательно)</label>
|
|
<input type="text" id="new-device-pin" placeholder="напр. GPIO25">
|
|
<button onclick="addDevice()">Создать устройство</button>
|
|
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему.</p>
|
|
</div>`;
|
|
}
|
|
|
|
function onNewDeviceKindChange() {
|
|
const kind = document.getElementById('new-device-kind').value;
|
|
document.getElementById('new-device-sensor-fields').style.display = kind === 'sensor' ? 'block' : 'none';
|
|
document.getElementById('new-device-actuator-fields').style.display = kind === 'actuator' ? 'block' : 'none';
|
|
}
|
|
|
|
async function addDevice() {
|
|
const kind = document.getElementById('new-device-kind').value;
|
|
const externalId = document.getElementById('new-device-external-id').value.trim();
|
|
const zoneId = document.getElementById('new-device-zone-id').value.trim();
|
|
if (!externalId || !zoneId) return;
|
|
|
|
const payload = { kind, external_id: externalId, zone_id: zoneId,
|
|
pin: document.getElementById('new-device-pin').value.trim() };
|
|
if (kind === 'sensor') {
|
|
const name = document.getElementById('new-device-signal-name').value.trim();
|
|
if (!name) return;
|
|
payload.signal_name = name;
|
|
payload.discrete = document.getElementById('new-device-discrete').checked;
|
|
} else {
|
|
payload.supports_level = document.getElementById('new-device-supports-level').checked;
|
|
}
|
|
|
|
const res = await fetch('/api/devices', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
alert('Не удалось создать устройство: ' + (await res.text()));
|
|
return;
|
|
}
|
|
document.getElementById('new-device-external-id').value = '';
|
|
document.getElementById('new-device-signal-name').value = '';
|
|
document.getElementById('new-device-pin').value = '';
|
|
}
|
|
|
|
async function removeDevice(slot) {
|
|
if (!confirm('Удалить это устройство?')) return;
|
|
await fetch(`/api/devices/${slot}`, { method: 'DELETE' });
|
|
}
|
|
|
|
function renderSignals(d) {
|
|
const container = document.getElementById(`${d.slot}-signals`);
|
|
const readings = d.readings || {};
|
|
const names = orderedNames(readings);
|
|
container.innerHTML = names.map((name) => {
|
|
// Core sensor's own readings (temperature/humidity) are always analog;
|
|
// an extra sensor device has exactly one reading, flagged by the
|
|
// server via d.discrete.
|
|
const discrete = !d.core && d.discrete;
|
|
return discrete ? discreteRowHTML(d.slot, name, readings[name]) : analogRowHTML(d.slot, name, readings[name]);
|
|
}).join('');
|
|
updateBoardText(d.slot, readings);
|
|
}
|
|
|
|
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 the server (and thus
|
|
// the SSE truth every client, including reconnects, converges to) is back
|
|
// in sync within a fraction of a second either way, not only when a
|
|
// separate "publish" button is remembered and 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(latest[slot]);
|
|
publishOne(slot, name, next); // discrete flips are instant, no debounce needed
|
|
}
|
|
|
|
function render(snapshot) {
|
|
const seenSlots = new Set();
|
|
|
|
for (const d of snapshot.devices) {
|
|
seenSlots.add(d.slot);
|
|
const prevPower = latest[d.slot]?.power;
|
|
const prevLevel = latest[d.slot]?.level;
|
|
latest[d.slot] = d;
|
|
|
|
if (!cardSlots.has(d.slot)) {
|
|
insertCard(d);
|
|
}
|
|
|
|
document.getElementById(`${d.slot}-external-id`).value = d.external_id;
|
|
document.getElementById(`${d.slot}-zone-id`).value = d.zone_id;
|
|
|
|
if (d.kind === 'sensor') {
|
|
renderSignals(d);
|
|
} else {
|
|
const badge = document.getElementById(`${d.slot}-badge`);
|
|
badge.textContent = d.power ? 'включено' : 'выключено';
|
|
badge.classList.toggle('on', d.power);
|
|
document.getElementById(`${d.slot}-power`).textContent = d.power ? 'вкл' : 'выкл';
|
|
|
|
if (d.supports_level) {
|
|
document.getElementById(`${d.slot}-level-row`).style.display = 'flex';
|
|
document.getElementById(`${d.slot}-level`).textContent = d.level;
|
|
}
|
|
|
|
if (prevPower !== undefined && (prevPower !== d.power || prevLevel !== d.level)) {
|
|
flashCard(d.slot);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop cards for devices that no longer exist (removed extras).
|
|
for (const slot of [...cardSlots]) {
|
|
if (!seenSlots.has(slot)) {
|
|
document.getElementById(`${slot}-card`)?.remove();
|
|
cardSlots.delete(slot);
|
|
delete latest[slot];
|
|
}
|
|
}
|
|
|
|
document.getElementById('fan-blades').classList.toggle('on', !!latest.fan?.power);
|
|
document.getElementById('bulb-glow').classList.toggle('on', !!latest.light?.power);
|
|
document.getElementById('bulb-body').setAttribute('fill', latest.light?.power ? '#e8b339' : '#233348');
|
|
document.getElementById('pump-ring').classList.toggle('on', !!latest.pump?.power);
|
|
|
|
renderExtensionDevices(snapshot.devices.filter((d) => !d.core));
|
|
}
|
|
|
|
function insertCard(d) {
|
|
const cardsEl = document.getElementById('cards');
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = cardHTML(d);
|
|
cardsEl.appendChild(wrapper.firstElementChild);
|
|
cardSlots.add(d.slot);
|
|
}
|
|
|
|
function flashCard(slot) {
|
|
const card = document.getElementById(`${slot}-card`);
|
|
card.classList.add('flash');
|
|
setTimeout(() => card.classList.remove('flash'), 900);
|
|
}
|
|
|
|
// Draws every non-core device as its own icon hanging off the "EXT" bus
|
|
// below the fixed board — visually distinct (dashed wire) from the four
|
|
// GPIO-wired peripherals, since these aren't on a real fixed pin.
|
|
function renderExtensionDevices(extras) {
|
|
const g = document.getElementById('extension-devices');
|
|
const trunk = document.getElementById('ext-trunk');
|
|
|
|
if (!extras.length) {
|
|
g.innerHTML = '';
|
|
trunk.setAttribute('d', 'M 350,320 L 350,360');
|
|
return;
|
|
}
|
|
|
|
const busY = 375;
|
|
const spacing = 110;
|
|
const totalWidth = spacing * (extras.length - 1);
|
|
const startX = 350 - totalWidth / 2;
|
|
trunk.setAttribute('d', `M 350,320 L 350,${busY}`);
|
|
|
|
let svg = `<line x1="${startX}" y1="${busY}" x2="${startX + totalWidth}" y2="${busY}" stroke="var(--wire-ext)" stroke-width="2" stroke-dasharray="4 3"/>`;
|
|
|
|
extras.forEach((d, i) => {
|
|
const x = startX + spacing * i;
|
|
const y = busY + 55;
|
|
svg += `<line x1="${x}" y1="${busY}" x2="${x}" y2="${y - 26}" stroke="var(--wire-ext)" stroke-width="2" stroke-dasharray="4 3"/>`;
|
|
if (d.pin) {
|
|
svg += `<text x="${x + 6}" y="${busY + 14}" font-size="9" fill="var(--wire-ext)">${escapeHTML(d.pin)}</text>`;
|
|
}
|
|
|
|
const title = escapeHTML(deviceTitle(d));
|
|
const pinPrefix = d.pin ? `${escapeHTML(d.pin)} · ` : '';
|
|
const sub = `${pinPrefix}${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
|
|
|
|
if (d.kind === 'sensor') {
|
|
const name = Object.keys(d.readings || {})[0];
|
|
const value = name !== undefined ? d.readings[name] : 0;
|
|
svg += `<circle cx="${x}" cy="${y}" r="24" fill="#233348" stroke="var(--wire-sensor)" stroke-width="2"/>`;
|
|
svg += `<text x="${x}" y="${y + 5}" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="700">${escapeHTML(value)}</text>`;
|
|
} else {
|
|
const on = !!d.power;
|
|
svg += `<circle cx="${x}" cy="${y}" r="24" fill="${on ? 'var(--accent-dim)' : '#233348'}" stroke="var(--wire-fan)" stroke-width="2"/>`;
|
|
svg += `<text x="${x}" y="${y + 4}" text-anchor="middle" font-size="10" fill="${on ? 'var(--accent)' : '#8ea0b8'}" font-weight="700">${on ? 'ON' : 'OFF'}</text>`;
|
|
}
|
|
svg += `<text class="peripheral-label" x="${x}" y="${y + 42}">${title}</text>`;
|
|
svg += `<text class="peripheral-sub" x="${x}" y="${y + 53}">${sub}</text>`;
|
|
});
|
|
|
|
g.innerHTML = svg;
|
|
}
|
|
|
|
async function saveIdentity(slot) {
|
|
const external_id = document.getElementById(`${slot}-external-id`).value.trim();
|
|
const zone_id = document.getElementById(`${slot}-zone-id`).value.trim();
|
|
await fetch(`/api/devices/${slot}/identity`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ external_id, zone_id }),
|
|
});
|
|
}
|
|
|
|
async function publishOne(slot, sensorType, value) {
|
|
await fetch(`/api/devices/${slot}/telemetry`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sensor_type: sensorType, value: parseFloat(value) }),
|
|
});
|
|
}
|
|
|
|
function connectEvents() {
|
|
const es = new EventSource('/api/events');
|
|
const conn = document.getElementById('conn');
|
|
const label = document.getElementById('conn-label');
|
|
|
|
es.onopen = () => { conn.classList.add('online'); conn.classList.remove('offline'); label.textContent = 'подключено'; };
|
|
es.onerror = () => { conn.classList.remove('online'); conn.classList.add('offline'); label.textContent = 'переподключение…'; };
|
|
es.onmessage = (e) => render(JSON.parse(e.data));
|
|
}
|
|
|
|
document.getElementById('cards').innerHTML = addDeviceCardHTML();
|
|
connectEvents();
|
|
</script>
|
|
</body>
|
|
</html>
|