По фото: у нас два одинаковых 2-канальных релейных модуля (не один) — второй закрывает fan-1 по-настоящему (GPIO18), больше не "пока не куплено". Добавлен soil-1: компараторный модуль почвенной влажности, читаем A0 через ADC (GPIO34, input-only), публикуем sensor_type=soil_moisture в процентах. Raw->% калибруется через SOIL_RAW_DRY/SOIL_RAW_WET в config.h — заводские значения приблизительные, точную калибровку раньше сделать было нельзя (не было физического доступа к датчику), нужно измерить на месте. soil-1 в Postgres/Laravel платформы ещё не заведён (не трогаем home_automatization) — телеметрия будет идти в ClickHouse, но в Laravel UI устройство не появится до отдельной регистрации device_type/devices-записи. Веб-интерфейс и /api/state дополнены полем "soil".
161 lines
5.3 KiB
C++
161 lines
5.3 KiB
C++
#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">
|
||
<div class="row"><span>Влажность почвы</span><span id="soil" class="sensor-value">—</span></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';
|
||
document.getElementById('soil').textContent = state.soil.has_reading ? state.soil.percent.toFixed(0) + ' %' : '—';
|
||
|
||
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;
|
||
}
|
||
|
||
float soilPercent;
|
||
bool hasSoilReading = telemetryGetLastSoil(soilPercent);
|
||
JsonObject soil = doc["soil"].to<JsonObject>();
|
||
soil["has_reading"] = hasSoilReading;
|
||
if (hasSoilReading) {
|
||
soil["percent"] = soilPercent;
|
||
}
|
||
|
||
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();
|
||
}
|