Files
cacto bb85c617a4 Добавлен локальный веб-интерфейс и распиновка в README
Плата поднимает страницу + JSON API (/api/state, /api/command) на порту 80
для наблюдения и управления актуаторами независимо от MQTT/Laravel.
Команда с веб-UI публикует тот же ack в devices/{id}/ack, что и MQTT-команда,
поэтому device shadow платформы не расходится с реальным состоянием.
Включается/выключается флагом ENABLE_WEB_UI в config.h.

В README добавлены ASCII-схема подключения DHT22/реле и раздел про сборку
через arduino-cli (включая обходной путь для проблемы с правами на
~/Documents/Arduino на macOS).
2026-08-11 19:27:41 +05:00

149 lines
4.8 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.
#include "web_ui.h"
#include <ArduinoJson.h>
#include <WebServer.h>
#include "devices.h"
#include "mqtt_link.h"
#include "telemetry.h"
static WebServer server(80);
static const char PAGE_HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Гроубокс</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; background:#0f172a; color:#e2e8f0; }
h1 { font-size: 1.25rem; }
.card { background:#1e293b; border-radius: 12px; padding: 1rem; margin-bottom: 1rem; }
.row { display:flex; justify-content:space-between; align-items:center; margin: .5rem 0; }
button { border:0; border-radius:8px; padding:.5rem 1.1rem; font-size:1rem; cursor:pointer; }
.on { background:#22c55e; color:#052e16; }
.off { background:#334155; color:#e2e8f0; }
.sensor-value { font-size:1.4rem; font-weight:600; }
.muted { color:#94a3b8; font-size:.85rem; }
</style>
</head>
<body>
<h1>Гроубокс</h1>
<div class="card">
<div class="row"><span>Температура</span><span id="temp" class="sensor-value">—</span></div>
<div class="row"><span>Влажность</span><span id="hum" class="sensor-value">—</span></div>
<div class="muted" id="updated">нет данных</div>
</div>
<div class="card" id="actuators"></div>
<script>
const DEVICES = [
{ id: 'light-1', label: 'Свет' },
{ id: 'pump-1', label: 'Насос' },
{ id: 'fan-1', label: 'Вентилятор' },
];
function render(state) {
document.getElementById('temp').textContent = state.sensor.has_reading ? state.sensor.temperature.toFixed(1) + ' °C' : '—';
document.getElementById('hum').textContent = state.sensor.has_reading ? state.sensor.humidity.toFixed(0) + ' %' : '—';
document.getElementById('updated').textContent = state.sensor.has_reading ? 'данные с DHT22 получены' : 'нет данных с DHT22';
const box = document.getElementById('actuators');
box.innerHTML = '';
DEVICES.forEach(function (d) {
const on = state.devices[d.id] && state.devices[d.id].power === 'on';
const row = document.createElement('div');
row.className = 'row';
const label = document.createElement('span');
label.textContent = d.label;
const btn = document.createElement('button');
btn.className = on ? 'on' : 'off';
btn.textContent = on ? 'Вкл' : 'Выкл';
btn.onclick = function () { sendCommand(d.id, on ? 'turn_off' : 'turn_on'); };
row.appendChild(label);
row.appendChild(btn);
box.appendChild(row);
});
}
function sendCommand(deviceId, action) {
fetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId, action: action }),
}).then(refresh);
}
function refresh() {
fetch('/api/state').then(function (r) { return r.json(); }).then(render);
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
)HTML";
static void handleRoot() {
server.send_P(200, "text/html", PAGE_HTML);
}
static void handleState() {
JsonDocument doc;
float temperature, humidity;
bool hasReading = telemetryGetLast(temperature, humidity);
JsonObject sensor = doc["sensor"].to<JsonObject>();
sensor["has_reading"] = hasReading;
if (hasReading) {
sensor["temperature"] = temperature;
sensor["humidity"] = humidity;
}
JsonObject devicesObj = doc["devices"].to<JsonObject>();
devicesGetState(devicesObj);
String body;
serializeJson(doc, body);
server.send(200, "application/json", body);
}
static void handleCommand() {
JsonDocument cmd;
DeserializationError parseErr = deserializeJson(cmd, server.arg("plain"));
if (parseErr) {
server.send(400, "application/json", "{\"error\":\"invalid json\"}");
return;
}
String deviceId = cmd["device_id"] | "";
String action = cmd["action"] | "";
double level = cmd["level"] | 0.0;
JsonDocument state;
// Тот же путь применения команды и публикации ack, что и у MQTT-команд
// (см. mqtt_link.cpp) — device shadow платформы остаётся консистентным
// независимо от того, кто отдал команду: Laravel/rule-engine или веб-UI.
if (!devicesApplyCommand(deviceId, action, level, state)) {
server.send(404, "application/json", "{\"error\":\"unknown device or action\"}");
return;
}
mqttPublishAck(deviceId, state);
String body;
serializeJson(state, body);
server.send(200, "application/json", body);
}
void webUiBegin() {
server.on("/", HTTP_GET, handleRoot);
server.on("/api/state", HTTP_GET, handleState);
server.on("/api/command", HTTP_POST, handleCommand);
server.begin();
Serial.println("[web] UI started on port 80");
}
void webUiHandle() {
server.handleClient();
}