Go-сервис без внешних фронтенд-зависимостей: embedded HTML-страница
с SVG-изображением платы (датчик + вентилятор + свет + помпа) и
Server-Sent Events для live-обновления. Слушает devices/+/commands
и реально отвечает ack'ом, публикует devices/{id}/telemetry —
протокол зеркалит ingest-service/device-control-service основного
репозитория, общего кода между репозиториями нет. Дефолтные
external_id (sensor-1/fan-1/light-1/pump-1) совпадают с
DemoGrowboxSeeder платформы.
283 lines
11 KiB
HTML
283 lines
11 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;
|
|
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; }
|
|
.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: 520px; }
|
|
.board rect.case { fill: var(--panel); stroke: var(--border); stroke-width: 2; }
|
|
|
|
.fan-blades { transform-origin: center; 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(260px, 1fr));
|
|
gap: 16px;
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
}
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}
|
|
.card h2 {
|
|
font-size: 0.95rem; margin: 0 0 12px;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.badge {
|
|
font-size: 0.72rem; padding: 2px 8px; border-radius: 999px;
|
|
background: var(--panel-2); color: var(--muted); font-weight: 500;
|
|
}
|
|
.badge.on { background: var(--accent-dim); color: var(--accent); }
|
|
|
|
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; }
|
|
.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: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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>ESP32 Emulator</h1>
|
|
<p class="subtitle">Виртуальный контроллер гроубокса — датчик + вентилятор + свет + помпа, говорит по тому же MQTT-протоколу, что и настоящее устройство.</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 400 220" xmlns="http://www.w3.org/2000/svg">
|
|
<rect class="case" x="10" y="10" width="380" height="200" rx="14"/>
|
|
|
|
<!-- sensor -->
|
|
<g transform="translate(60,60)">
|
|
<circle r="30" fill="#233348" stroke="#37527a" stroke-width="2"/>
|
|
<text id="sensor-temp" x="0" y="-2" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="600">24°C</text>
|
|
<text id="sensor-humidity" x="0" y="14" text-anchor="middle" font-size="10" fill="#8ea0b8">55%</text>
|
|
</g>
|
|
<text x="60" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">датчик</text>
|
|
|
|
<!-- fan -->
|
|
<g transform="translate(160,60)">
|
|
<circle r="30" fill="#233348" stroke="#37527a" 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 x="160" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">вентилятор</text>
|
|
|
|
<!-- light -->
|
|
<g transform="translate(260,60)">
|
|
<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"/>
|
|
</g>
|
|
<text x="260" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">свет</text>
|
|
|
|
<!-- pump -->
|
|
<g transform="translate(340,60)">
|
|
<circle id="pump-ring" class="pump-ring" r="26" fill="none" stroke="#3aa0e8" stroke-width="3" opacity="0"/>
|
|
<circle r="16" fill="#233348" stroke="#37527a" stroke-width="2"/>
|
|
<path d="M0,-8 C6,-2 6,6 0,8 C-6,6 -6,-2 0,-8 Z" fill="#3aa0e8"/>
|
|
</g>
|
|
<text x="340" y="115" text-anchor="middle" font-size="11" fill="#8ea0b8">помпа</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="grid" id="cards"></div>
|
|
|
|
<script>
|
|
const SLOTS = {
|
|
sensor: { label: 'Датчик температуры/влажности', kind: 'sensor' },
|
|
fan: { label: 'Вентилятор', kind: 'actuator' },
|
|
light: { label: 'Свет', kind: 'actuator' },
|
|
pump: { label: 'Помпа', kind: 'actuator' },
|
|
};
|
|
|
|
let latest = {};
|
|
|
|
function cardHTML(slot) {
|
|
const meta = SLOTS[slot];
|
|
const idPrefix = slot;
|
|
let body = `
|
|
<label>external_id</label>
|
|
<input type="text" id="${idPrefix}-external-id">
|
|
<label>zone_id</label>
|
|
<input type="text" id="${idPrefix}-zone-id">
|
|
<button class="secondary" onclick="saveIdentity('${slot}')">Сохранить идентификатор</button>
|
|
`;
|
|
|
|
if (meta.kind === 'sensor') {
|
|
body += `
|
|
<label>Температура: <span class="value" id="${idPrefix}-temp-value">24</span> °C</label>
|
|
<input type="range" id="${idPrefix}-temp" min="-10" max="50" step="0.5" value="24">
|
|
<label>Влажность: <span class="value" id="${idPrefix}-humidity-value">55</span> %</label>
|
|
<input type="range" id="${idPrefix}-humidity" min="0" max="100" step="1" value="55">
|
|
<button onclick="publishTelemetry('${slot}')">Опубликовать показания</button>
|
|
`;
|
|
} else {
|
|
body += `
|
|
<div class="status-line"><span>Питание</span><span id="${idPrefix}-power">—</span></div>
|
|
<div class="status-line" id="${idPrefix}-level-row" style="display:none">
|
|
<span>Уровень</span><span id="${idPrefix}-level">—</span>
|
|
</div>
|
|
<p style="color:var(--muted); font-size:0.78rem; margin-top:10px;">
|
|
Управляется командами от платформы — здесь только отображение.
|
|
</p>
|
|
`;
|
|
}
|
|
|
|
const badge = meta.kind === 'actuator' ? `<span class="badge" id="${idPrefix}-badge">выключено</span>` : '';
|
|
return `<div class="card">
|
|
<h2>${meta.label} ${badge}</h2>
|
|
${body}
|
|
</div>`;
|
|
}
|
|
|
|
document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join('');
|
|
|
|
function render(snapshot) {
|
|
latest = {};
|
|
for (const d of snapshot.devices) {
|
|
latest[d.slot] = 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') {
|
|
const temp = d.readings?.temperature ?? 0;
|
|
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 {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
for (const slot of Object.keys(SLOTS)) {
|
|
if (SLOTS[slot].kind !== 'sensor') continue;
|
|
document.getElementById(`${slot}-temp`).addEventListener('input', (e) => {
|
|
document.getElementById(`${slot}-temp-value`).textContent = e.target.value;
|
|
});
|
|
document.getElementById(`${slot}-humidity`).addEventListener('input', (e) => {
|
|
document.getElementById(`${slot}-humidity-value`).textContent = e.target.value;
|
|
});
|
|
}
|
|
|
|
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 publishTelemetry(slot) {
|
|
const temp = parseFloat(document.getElementById(`${slot}-temp`).value);
|
|
const humidity = parseFloat(document.getElementById(`${slot}-humidity`).value);
|
|
await fetch(`/api/devices/${slot}/telemetry`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sensor_type: 'temperature', value: temp }),
|
|
});
|
|
await fetch(`/api/devices/${slot}/telemetry`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sensor_type: 'humidity', value: humidity }),
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
connectEvents();
|
|
</script>
|
|
</body>
|
|
</html>
|