585 lines
28 KiB
C
585 lines
28 KiB
C
#include "status_web.h"
|
||
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
#include <strings.h>
|
||
|
||
#include "cJSON.h"
|
||
#include "auto_ctrl_thresholds.h"
|
||
#include "esp_check.h"
|
||
#include "esp_http_server.h"
|
||
#include "esp_log.h"
|
||
#include "esp_netif.h"
|
||
#include "esp_netif_ip_addr.h"
|
||
#include "esp_timer.h"
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/semphr.h"
|
||
#include "mqtt_control.h"
|
||
#include "wifi-connect.h"
|
||
|
||
static const char *TAG = "status_web";
|
||
|
||
static httpd_handle_t s_server = NULL;
|
||
static SemaphoreHandle_t s_lock = NULL;
|
||
static uint64_t s_snapshot_update_ms = 0;
|
||
static mqtt_control_command_handler_t s_control_handler = NULL;
|
||
static void *s_control_user_ctx = NULL;
|
||
static status_web_snapshot_t s_snapshot = {
|
||
.temp = "--",
|
||
.hum = "--",
|
||
.lux = "--",
|
||
.fan_on = false,
|
||
.light_on = false,
|
||
.hot_on = false,
|
||
.cool_on = false,
|
||
.auto_mode = true,
|
||
.light_on_threshold = 100.0f,
|
||
.light_off_threshold = 350.0f,
|
||
.hot_on_temp_threshold = 18.0f,
|
||
.hot_off_temp_threshold = 20.0f,
|
||
.cool_on_temp_threshold = 30.0f,
|
||
.cool_off_temp_threshold = 28.0f,
|
||
.fan_on_hum_threshold = 80.0f,
|
||
.fan_off_hum_threshold = 70.0f,
|
||
.i2c_ready = false,
|
||
.loop_counter = 0,
|
||
};
|
||
|
||
static const char *wifi_status_text(wifi_connect_status_t status)
|
||
{
|
||
switch (status) {
|
||
case WIFI_CONNECT_STATUS_IDLE: return "idle";
|
||
case WIFI_CONNECT_STATUS_PROVISIONING: return "provisioning";
|
||
case WIFI_CONNECT_STATUS_CONNECTING: return "connecting";
|
||
case WIFI_CONNECT_STATUS_CONNECTED: return "connected";
|
||
case WIFI_CONNECT_STATUS_FAILED: return "failed";
|
||
case WIFI_CONNECT_STATUS_TIMEOUT: return "timeout";
|
||
default: return "unknown";
|
||
}
|
||
}
|
||
|
||
static bool json_read_number(cJSON *root, const char *key, float *out)
|
||
{
|
||
cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||
if (!cJSON_IsNumber(item) || out == NULL) {
|
||
return false;
|
||
}
|
||
*out = (float)item->valuedouble;
|
||
return true;
|
||
}
|
||
|
||
static bool json_read_bool(cJSON *root, const char *key, bool *out)
|
||
{
|
||
cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||
if (item == NULL || out == NULL) {
|
||
return false;
|
||
}
|
||
|
||
if (cJSON_IsBool(item)) {
|
||
*out = cJSON_IsTrue(item);
|
||
return true;
|
||
}
|
||
if (cJSON_IsNumber(item)) {
|
||
*out = (item->valuedouble != 0.0);
|
||
return true;
|
||
}
|
||
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||
const char *s = item->valuestring;
|
||
if (strcasecmp(s, "on") == 0 || strcasecmp(s, "true") == 0 || strcmp(s, "1") == 0) {
|
||
*out = true;
|
||
return true;
|
||
}
|
||
if (strcasecmp(s, "off") == 0 || strcasecmp(s, "false") == 0 || strcmp(s, "0") == 0) {
|
||
*out = false;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool json_read_mode_auto(cJSON *root, const char *key, bool *out_auto)
|
||
{
|
||
cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||
if (item == NULL || out_auto == NULL) {
|
||
return false;
|
||
}
|
||
|
||
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||
if (strcasecmp(item->valuestring, "auto") == 0) {
|
||
*out_auto = true;
|
||
return true;
|
||
}
|
||
if (strcasecmp(item->valuestring, "manual") == 0) {
|
||
*out_auto = false;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (cJSON_IsBool(item)) {
|
||
*out_auto = cJSON_IsTrue(item);
|
||
return true;
|
||
}
|
||
if (cJSON_IsNumber(item)) {
|
||
*out_auto = (item->valuedouble != 0.0);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static void get_sta_ip_text(char *out, size_t out_size)
|
||
{
|
||
if (out == NULL || out_size == 0) {
|
||
return;
|
||
}
|
||
|
||
snprintf(out, out_size, "--");
|
||
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||
if (sta == NULL) {
|
||
return;
|
||
}
|
||
|
||
esp_netif_ip_info_t ip_info;
|
||
if (esp_netif_get_ip_info(sta, &ip_info) == ESP_OK) {
|
||
snprintf(out,
|
||
out_size,
|
||
IPSTR,
|
||
IP2STR(&ip_info.ip));
|
||
}
|
||
}
|
||
|
||
static const char *s_page_html =
|
||
"<!doctype html><html><head><meta charset='utf-8'/>"
|
||
"<meta name='viewport' content='width=device-width, initial-scale=1'/>"
|
||
"<title>BotanicalBuddy Status</title>"
|
||
"<style>"
|
||
"body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#eef2f7;margin:0;padding:14px;color:#111827;}"
|
||
".wrap{max-width:1080px;margin:0 auto;background:#fff;border-radius:14px;padding:14px;box-shadow:0 8px 24px rgba(0,0,0,.08);}"
|
||
"h1{font-size:20px;margin:0 0 10px;}"
|
||
".meta{font-size:12px;color:#6b7280;margin-bottom:10px;}"
|
||
".sec{margin-top:10px;}"
|
||
".sec h2{font-size:14px;margin:0 0 6px;color:#374151;}"
|
||
".grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}"
|
||
"@media(max-width:900px){.grid{grid-template-columns:1fr 1fr;}}"
|
||
"@media(max-width:560px){.grid{grid-template-columns:1fr;}}"
|
||
".card{padding:10px;border:1px solid #e5e7eb;border-radius:10px;background:#fafafa;}"
|
||
".k{font-size:12px;color:#6b7280;}"
|
||
".v{font-size:17px;font-weight:600;margin-top:2px;word-break:break-all;}"
|
||
"input,select{margin-top:6px;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;background:#fff;color:#111827;}"
|
||
"button{margin-top:8px;border:none;border-radius:8px;background:#1d4ed8;color:#fff;padding:8px 12px;cursor:pointer;}"
|
||
"button:disabled{opacity:.45;cursor:not-allowed;}"
|
||
".btn-row{display:flex;gap:8px;margin-top:8px;}"
|
||
".btn{flex:1;background:#64748b;}"
|
||
".btn.on{background:#16a34a;}"
|
||
".btn.off{background:#dc2626;}"
|
||
".badge{display:inline-block;padding:3px 8px;border-radius:999px;font-size:12px;font-weight:600;}"
|
||
".badge.auto{background:#dcfce7;color:#166534;}"
|
||
".badge.manual{background:#dbeafe;color:#1e3a8a;}"
|
||
"</style></head><body><div class='wrap'>"
|
||
"<h1>智能粮仓终端设备状态总览</h1>"
|
||
"<div class='meta'>独立状态服务(port 8080),每3秒自动刷新</div>"
|
||
"<div class='sec'><h2>传感与控制</h2><div class='grid'>"
|
||
"<div class='card'><div class='k'>空气温度</div><div id='temp' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>空气湿度</div><div id='hum' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>光照强度</div><div id='lux' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>风扇</div><div id='fan' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>补光灯</div><div id='light' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>加热</div><div id='hot' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>制冷</div><div id='cool' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>控制模式</div><div id='mode' class='v'>--</div><div id='mode_badge' class='badge manual'>manual</div></div>"
|
||
"<div class='card'><div class='k'>light_on/off</div><div id='light_th' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>hot_on/off (C)</div><div id='hot_th' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>cool_on/off (C)</div><div id='cool_th' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>fan_hum_on/off (%)</div><div id='fan_hum_th' class='v'>--</div></div>"
|
||
"</div></div>"
|
||
"<div class='sec'><h2>参数设置</h2><div class='grid'>"
|
||
"<div class='card'><div class='k'>light_on</div><input id='f_light_on' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>light_off</div><input id='f_light_off' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>hot_on_temp</div><input id='f_hot_on' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>hot_off_temp</div><input id='f_hot_off' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>cool_on_temp</div><input id='f_cool_on' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>cool_off_temp</div><input id='f_cool_off' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>fan_on_hum</div><input id='f_fan_on' type='number' step='0.1' style='width:100%'></div>"
|
||
"<div class='card'><div class='k'>fan_off_hum</div><input id='f_fan_off' type='number' step='0.1' style='width:100%'></div>"
|
||
"</div><button onclick='saveCfg()'>保存参数</button> <span id='save_msg' class='meta'></span></div>"
|
||
"<div class='sec'><h2>快捷控制</h2><div class='grid'>"
|
||
"<div class='card'><div class='k'>模式</div><select id='mode_sel' style='width:100%'><option value='auto'>auto</option><option value='manual'>manual</option></select><button id='mode_btn' onclick='setMode()'>切换模式</button></div>"
|
||
"<div class='card'><div class='k'>风扇</div><div class='btn-row'><button id='fan_on_btn' class='btn' onclick='devCmd(\"fan\",true)'>ON</button><button id='fan_off_btn' class='btn' onclick='devCmd(\"fan\",false)'>OFF</button></div></div>"
|
||
"<div class='card'><div class='k'>补光灯</div><div class='btn-row'><button id='light_on_btn' class='btn' onclick='devCmd(\"light\",true)'>ON</button><button id='light_off_btn' class='btn' onclick='devCmd(\"light\",false)'>OFF</button></div></div>"
|
||
"<div class='card'><div class='k'>加热</div><div class='btn-row'><button id='hot_on_btn' class='btn' onclick='devCmd(\"hot\",true)'>ON</button><button id='hot_off_btn' class='btn' onclick='devCmd(\"hot\",false)'>OFF</button></div></div>"
|
||
"<div class='card'><div class='k'>制冷</div><div class='btn-row'><button id='cool_on_btn' class='btn' onclick='devCmd(\"cool\",true)'>ON</button><button id='cool_off_btn' class='btn' onclick='devCmd(\"cool\",false)'>OFF</button></div></div>"
|
||
"</div><span id='ctrl_msg' class='meta'></span></div>"
|
||
"<div class='sec'><h2>连接与系统</h2><div class='grid'>"
|
||
"<div class='card'><div class='k'>Wi-Fi 状态</div><div id='wifi' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>STA IP</div><div id='ip' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>MQTT 连接</div><div id='mqtt' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>I2C Ready</div><div id='i2c' class='v'>--</div></div>"
|
||
"<div class='card'><div class='k'>运行时长</div><div id='uptime' class='v'>--</div></div>"
|
||
"</div></div>"
|
||
"<button onclick='loadStatus()'>立即刷新</button>"
|
||
"</div><script>"
|
||
"const $=(id)=>document.getElementById(id);const cfgIds=['f_light_on','f_light_off','f_hot_on','f_hot_off','f_cool_on','f_cool_off','f_fan_on','f_fan_off'];let busyCtrl=false;let busyCfg=false;let cfgDirty=false;"
|
||
"function onoff(v){return v?'on':'off';}"
|
||
"function yn(v){return v?'yes':'no';}"
|
||
"function setBinState(name,on){const bOn=document.getElementById(name+'_on_btn');const bOff=document.getElementById(name+'_off_btn');if(!bOn||!bOff)return;bOn.classList.remove('on','off');bOff.classList.remove('on','off');if(on){bOn.classList.add('on');bOff.classList.add('off');}else{bOn.classList.add('off');bOff.classList.add('on');}}"
|
||
"function setManualEnabled(enabled){['fan','light','hot','cool'].forEach((n)=>{const bOn=document.getElementById(n+'_on_btn');const bOff=document.getElementById(n+'_off_btn');if(bOn)bOn.disabled=!enabled||busyCtrl;if(bOff)bOff.disabled=!enabled||busyCtrl;});}"
|
||
"function setModeBadge(mode){const el=document.getElementById('mode_badge');if(!el)return;el.textContent=mode;el.className='badge '+(mode==='auto'?'auto':'manual');}"
|
||
"function fmtMs(ms){if(ms<1000)return ms+' ms';const s=Math.floor(ms/1000);if(s<60)return s+' s';const m=Math.floor(s/60);const rs=s%60;if(m<60)return m+'m '+rs+'s';const h=Math.floor(m/60);return h+'h '+(m%60)+'m';}"
|
||
"function markCfgDirty(){cfgDirty=true;}"
|
||
"function applyCfgFromStatus(d){const a=document.activeElement;const editing=a&&cfgIds.includes(a.id);if(editing||cfgDirty||busyCfg)return;$('f_light_on').value=d.light_on;$('f_light_off').value=d.light_off;$('f_hot_on').value=d.hot_on_temp;$('f_hot_off').value=d.hot_off_temp;$('f_cool_on').value=d.cool_on_temp;$('f_cool_off').value=d.cool_off_temp;$('f_fan_on').value=d.fan_on_hum;$('f_fan_off').value=d.fan_off_hum;}"
|
||
"async function loadStatus(){try{const r=await fetch('/api/status');if(!r.ok){throw new Error('HTTP '+r.status);}const d=await r.json();"
|
||
"$('temp').textContent=d.temp;$('hum').textContent=d.hum;$('lux').textContent=d.lux;"
|
||
"$('fan').textContent=d.fan;$('light').textContent=d.light;$('hot').textContent=d.hot;$('cool').textContent=d.cool;$('mode').textContent=d.mode;"
|
||
"setModeBadge(d.mode);setBinState('fan',d.fan==='on');setBinState('light',d.light==='on');setBinState('hot',d.hot==='on');setBinState('cool',d.cool==='on');"
|
||
"$('light_th').textContent=`${d.light_on}/${d.light_off}`;"
|
||
"$('hot_th').textContent=`${d.hot_on_temp}/${d.hot_off_temp}`;"
|
||
"$('cool_th').textContent=`${d.cool_on_temp}/${d.cool_off_temp}`;"
|
||
"$('fan_hum_th').textContent=`${d.fan_on_hum}/${d.fan_off_hum}`;"
|
||
"applyCfgFromStatus(d);"
|
||
"$('mode_sel').value=d.mode;setManualEnabled(d.mode!=='auto');"
|
||
"$('wifi').textContent=d.wifi_status;$('ip').textContent=d.sta_ip;$('mqtt').textContent=onoff(d.mqtt_connected);"
|
||
"$('i2c').textContent=yn(d.i2c_ready);"
|
||
"$('uptime').textContent=fmtMs(d.uptime_ms);"
|
||
"$('save_msg').textContent='';}catch(e){$('save_msg').textContent='读取失败: '+e;}}"
|
||
"async function sendControl(p){if(busyCtrl)return;busyCtrl=true;setManualEnabled($('mode_sel').value!=='auto');$('mode_btn').disabled=true;try{const r=await fetch('/api/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});const d=await r.json();if(!r.ok||!d.ok){throw new Error(d.error||('HTTP '+r.status));}$('ctrl_msg').textContent='控制成功';await loadStatus();}"
|
||
"catch(e){$('ctrl_msg').textContent='控制失败: '+e;}"
|
||
"finally{busyCtrl=false;$('mode_btn').disabled=false;setManualEnabled($('mode_sel').value!=='auto');}}"
|
||
"function setMode(){sendControl({mode:$('mode_sel').value});}"
|
||
"function devCmd(name,on){if($('mode_sel').value==='auto'){$('ctrl_msg').textContent='auto 模式下请先切到 manual';return;}const p={};p[name]=on;sendControl(p);}"
|
||
"async function saveCfg(){const p={"
|
||
"light_on:parseFloat($('f_light_on').value),light_off:parseFloat($('f_light_off').value),"
|
||
"hot_on_temp:parseFloat($('f_hot_on').value),hot_off_temp:parseFloat($('f_hot_off').value),"
|
||
"cool_on_temp:parseFloat($('f_cool_on').value),cool_off_temp:parseFloat($('f_cool_off').value),"
|
||
"fan_on_hum:parseFloat($('f_fan_on').value),fan_off_hum:parseFloat($('f_fan_off').value)};"
|
||
"if(busyCfg)return;busyCfg=true;"
|
||
"try{const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});const d=await r.json();if(!r.ok||!d.ok){throw new Error(d.error||('HTTP '+r.status));}cfgDirty=false;$('save_msg').textContent='保存成功';await loadStatus();}"
|
||
"catch(e){$('save_msg').textContent='保存失败: '+e;}finally{busyCfg=false;}}"
|
||
"cfgIds.forEach((id)=>{const el=$(id);if(el){el.addEventListener('input',markCfgDirty);el.addEventListener('change',markCfgDirty);}});setInterval(loadStatus,3000);loadStatus();"
|
||
"</script></body></html>";
|
||
|
||
static esp_err_t status_root_handler(httpd_req_t *req)
|
||
{
|
||
httpd_resp_set_type(req, "text/html");
|
||
return httpd_resp_send(req, s_page_html, HTTPD_RESP_USE_STRLEN);
|
||
}
|
||
|
||
static esp_err_t status_favicon_handler(httpd_req_t *req)
|
||
{
|
||
httpd_resp_set_status(req, "204 No Content");
|
||
return httpd_resp_send(req, NULL, 0);
|
||
}
|
||
|
||
static esp_err_t status_api_handler(httpd_req_t *req)
|
||
{
|
||
status_web_snapshot_t snap;
|
||
uint64_t snapshot_update_ms = 0;
|
||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||
snap = s_snapshot;
|
||
snapshot_update_ms = s_snapshot_update_ms;
|
||
xSemaphoreGive(s_lock);
|
||
|
||
uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000);
|
||
const bool mqtt_connected = mqtt_control_is_connected();
|
||
const wifi_connect_status_t wifi_status = wifi_connect_get_status();
|
||
char ip_text[16] = {0};
|
||
get_sta_ip_text(ip_text, sizeof(ip_text));
|
||
|
||
uint64_t uptime_ms = now_ms;
|
||
if (snapshot_update_ms > 0 && now_ms < snapshot_update_ms) {
|
||
uptime_ms = snapshot_update_ms;
|
||
}
|
||
|
||
char json[620];
|
||
int len = snprintf(json,
|
||
sizeof(json),
|
||
"{\"temp\":\"%s\",\"hum\":\"%s\",\"lux\":\"%s\",\"fan\":\"%s\",\"light\":\"%s\",\"hot\":\"%s\",\"cool\":\"%s\",\"mode\":\"%s\",\"light_on\":%.1f,\"light_off\":%.1f,\"hot_on_temp\":%.1f,\"hot_off_temp\":%.1f,\"cool_on_temp\":%.1f,\"cool_off_temp\":%.1f,\"fan_on_hum\":%.1f,\"fan_off_hum\":%.1f,\"wifi_status\":\"%s\",\"sta_ip\":\"%s\",\"mqtt_connected\":%s,\"i2c_ready\":%s,\"uptime_ms\":%llu}",
|
||
snap.temp,
|
||
snap.hum,
|
||
snap.lux,
|
||
snap.fan_on ? "on" : "off",
|
||
snap.light_on ? "on" : "off",
|
||
snap.hot_on ? "on" : "off",
|
||
snap.cool_on ? "on" : "off",
|
||
snap.auto_mode ? "auto" : "manual",
|
||
snap.light_on_threshold,
|
||
snap.light_off_threshold,
|
||
snap.hot_on_temp_threshold,
|
||
snap.hot_off_temp_threshold,
|
||
snap.cool_on_temp_threshold,
|
||
snap.cool_off_temp_threshold,
|
||
snap.fan_on_hum_threshold,
|
||
snap.fan_off_hum_threshold,
|
||
wifi_status_text(wifi_status),
|
||
ip_text,
|
||
mqtt_connected ? "true" : "false",
|
||
snap.i2c_ready ? "true" : "false",
|
||
(unsigned long long)uptime_ms);
|
||
if (len <= 0 || len >= (int)sizeof(json)) {
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
httpd_resp_set_type(req, "application/json");
|
||
return httpd_resp_sendstr(req, json);
|
||
}
|
||
|
||
static esp_err_t status_config_handler(httpd_req_t *req)
|
||
{
|
||
if (req->content_len <= 0 || req->content_len > 512) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid content length\"}");
|
||
}
|
||
|
||
char body[513] = {0};
|
||
int received = 0;
|
||
while (received < req->content_len) {
|
||
int ret = httpd_req_recv(req, body + received, req->content_len - received);
|
||
if (ret <= 0) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"read body failed\"}");
|
||
}
|
||
received += ret;
|
||
}
|
||
|
||
cJSON *root = cJSON_ParseWithLength(body, (size_t)req->content_len);
|
||
if (root == NULL) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid json\"}");
|
||
}
|
||
|
||
float light_on = 0.0f;
|
||
float light_off = 0.0f;
|
||
float hot_on = 0.0f;
|
||
float hot_off = 0.0f;
|
||
float cool_on = 0.0f;
|
||
float cool_off = 0.0f;
|
||
float fan_on = 0.0f;
|
||
float fan_off = 0.0f;
|
||
|
||
bool ok = json_read_number(root, "light_on", &light_on) &&
|
||
json_read_number(root, "light_off", &light_off) &&
|
||
json_read_number(root, "hot_on_temp", &hot_on) &&
|
||
json_read_number(root, "hot_off_temp", &hot_off) &&
|
||
json_read_number(root, "cool_on_temp", &cool_on) &&
|
||
json_read_number(root, "cool_off_temp", &cool_off) &&
|
||
json_read_number(root, "fan_on_hum", &fan_on) &&
|
||
json_read_number(root, "fan_off_hum", &fan_off);
|
||
|
||
if (!ok) {
|
||
cJSON_Delete(root);
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"missing threshold fields\"}");
|
||
}
|
||
|
||
esp_err_t set_ret = auto_ctrl_thresholds_set_values(light_on,
|
||
light_off,
|
||
hot_on,
|
||
hot_off,
|
||
cool_on,
|
||
cool_off,
|
||
fan_on,
|
||
fan_off);
|
||
cJSON_Delete(root);
|
||
|
||
if (set_ret != ESP_OK) {
|
||
ESP_LOGW(TAG,
|
||
"web config reject: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f), err=%s",
|
||
light_on,
|
||
light_off,
|
||
hot_on,
|
||
hot_off,
|
||
cool_on,
|
||
cool_off,
|
||
fan_on,
|
||
fan_off,
|
||
esp_err_to_name(set_ret));
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid threshold range\"}");
|
||
}
|
||
|
||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||
s_snapshot.light_on_threshold = light_on;
|
||
s_snapshot.light_off_threshold = light_off;
|
||
s_snapshot.hot_on_temp_threshold = hot_on;
|
||
s_snapshot.hot_off_temp_threshold = hot_off;
|
||
s_snapshot.cool_on_temp_threshold = cool_on;
|
||
s_snapshot.cool_off_temp_threshold = cool_off;
|
||
s_snapshot.fan_on_hum_threshold = fan_on;
|
||
s_snapshot.fan_off_hum_threshold = fan_off;
|
||
s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000);
|
||
xSemaphoreGive(s_lock);
|
||
|
||
ESP_LOGI(TAG,
|
||
"web config saved: light(%.1f/%.1f) hot(%.1f/%.1f) cool(%.1f/%.1f) fan_hum(%.1f/%.1f)",
|
||
light_on,
|
||
light_off,
|
||
hot_on,
|
||
hot_off,
|
||
cool_on,
|
||
cool_off,
|
||
fan_on,
|
||
fan_off);
|
||
|
||
httpd_resp_set_type(req, "application/json");
|
||
return httpd_resp_sendstr(req, "{\"ok\":true}");
|
||
}
|
||
|
||
static esp_err_t status_control_handler(httpd_req_t *req)
|
||
{
|
||
if (s_control_handler == NULL) {
|
||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"control handler not ready\"}");
|
||
}
|
||
|
||
if (req->content_len <= 0 || req->content_len > 256) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid content length\"}");
|
||
}
|
||
|
||
char body[257] = {0};
|
||
int received = 0;
|
||
while (received < req->content_len) {
|
||
int ret = httpd_req_recv(req, body + received, req->content_len - received);
|
||
if (ret <= 0) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"read body failed\"}");
|
||
}
|
||
received += ret;
|
||
}
|
||
|
||
cJSON *root = cJSON_ParseWithLength(body, (size_t)req->content_len);
|
||
if (root == NULL) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"invalid json\"}");
|
||
}
|
||
|
||
mqtt_control_command_t cmd = {0};
|
||
cmd.has_mode = json_read_mode_auto(root, "mode", &cmd.auto_mode);
|
||
cmd.has_fan = json_read_bool(root, "fan", &cmd.fan_on);
|
||
cmd.has_light = json_read_bool(root, "light", &cmd.light_on);
|
||
cmd.has_hot = json_read_bool(root, "hot", &cmd.hot_on);
|
||
cmd.has_cool = json_read_bool(root, "cool", &cmd.cool_on);
|
||
cJSON_Delete(root);
|
||
|
||
if (!(cmd.has_mode || cmd.has_fan || cmd.has_light || cmd.has_hot || cmd.has_cool)) {
|
||
httpd_resp_set_status(req, "400 Bad Request");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"no valid control fields\"}");
|
||
}
|
||
|
||
esp_err_t ret = s_control_handler(&cmd, s_control_user_ctx);
|
||
if (ret != ESP_OK) {
|
||
ESP_LOGW(TAG, "web control apply failed: %s", esp_err_to_name(ret));
|
||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"control apply failed\"}");
|
||
}
|
||
|
||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||
if (cmd.has_mode) {
|
||
s_snapshot.auto_mode = cmd.auto_mode;
|
||
}
|
||
if (cmd.has_fan) {
|
||
s_snapshot.fan_on = cmd.fan_on;
|
||
}
|
||
if (cmd.has_light) {
|
||
s_snapshot.light_on = cmd.light_on;
|
||
}
|
||
if (cmd.has_hot) {
|
||
s_snapshot.hot_on = cmd.hot_on;
|
||
}
|
||
if (cmd.has_cool) {
|
||
s_snapshot.cool_on = cmd.cool_on;
|
||
}
|
||
s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000);
|
||
xSemaphoreGive(s_lock);
|
||
|
||
ESP_LOGI(TAG,
|
||
"web control ok: mode=%s fan=%s light=%s hot=%s cool=%s",
|
||
cmd.has_mode ? (cmd.auto_mode ? "auto" : "manual") : "-",
|
||
cmd.has_fan ? (cmd.fan_on ? "on" : "off") : "-",
|
||
cmd.has_light ? (cmd.light_on ? "on" : "off") : "-",
|
||
cmd.has_hot ? (cmd.hot_on ? "on" : "off") : "-",
|
||
cmd.has_cool ? (cmd.cool_on ? "on" : "off") : "-");
|
||
|
||
httpd_resp_set_type(req, "application/json");
|
||
return httpd_resp_sendstr(req, "{\"ok\":true}");
|
||
}
|
||
|
||
esp_err_t status_web_start(uint16_t port)
|
||
{
|
||
if (s_server != NULL) {
|
||
return ESP_OK;
|
||
}
|
||
|
||
if (s_lock == NULL) {
|
||
s_lock = xSemaphoreCreateMutex();
|
||
ESP_RETURN_ON_FALSE(s_lock != NULL, ESP_ERR_NO_MEM, TAG, "create mutex failed");
|
||
}
|
||
|
||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||
config.server_port = port;
|
||
config.ctrl_port = (uint16_t)(port + 1);
|
||
config.lru_purge_enable = true;
|
||
// Keep this <= (LWIP_MAX_SOCKETS - 3 internal sockets).
|
||
// Current target allows 7 total, so 4 is the safe upper bound.
|
||
config.max_open_sockets = 4;
|
||
|
||
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), TAG, "httpd_start failed");
|
||
|
||
const httpd_uri_t root = {
|
||
.uri = "/",
|
||
.method = HTTP_GET,
|
||
.handler = status_root_handler,
|
||
.user_ctx = NULL,
|
||
};
|
||
const httpd_uri_t api = {
|
||
.uri = "/api/status",
|
||
.method = HTTP_GET,
|
||
.handler = status_api_handler,
|
||
.user_ctx = NULL,
|
||
};
|
||
const httpd_uri_t icon = {
|
||
.uri = "/favicon.ico",
|
||
.method = HTTP_GET,
|
||
.handler = status_favicon_handler,
|
||
.user_ctx = NULL,
|
||
};
|
||
const httpd_uri_t cfg = {
|
||
.uri = "/api/config",
|
||
.method = HTTP_POST,
|
||
.handler = status_config_handler,
|
||
.user_ctx = NULL,
|
||
};
|
||
const httpd_uri_t ctrl = {
|
||
.uri = "/api/control",
|
||
.method = HTTP_POST,
|
||
.handler = status_control_handler,
|
||
.user_ctx = NULL,
|
||
};
|
||
|
||
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &root), TAG, "register root failed");
|
||
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &icon), TAG, "register favicon failed");
|
||
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &api), TAG, "register api failed");
|
||
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &cfg), TAG, "register config failed");
|
||
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_server, &ctrl), TAG, "register control failed");
|
||
|
||
ESP_LOGI(TAG, "status web started at port %u", (unsigned)port);
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t status_web_update(const status_web_snapshot_t *snapshot)
|
||
{
|
||
ESP_RETURN_ON_FALSE(snapshot != NULL, ESP_ERR_INVALID_ARG, TAG, "snapshot is null");
|
||
ESP_RETURN_ON_FALSE(s_lock != NULL, ESP_ERR_INVALID_STATE, TAG, "status web not started");
|
||
|
||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||
s_snapshot = *snapshot;
|
||
s_snapshot_update_ms = (uint64_t)(esp_timer_get_time() / 1000);
|
||
xSemaphoreGive(s_lock);
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t status_web_register_control_handler(mqtt_control_command_handler_t handler, void *user_ctx)
|
||
{
|
||
s_control_handler = handler;
|
||
s_control_user_ctx = user_ctx;
|
||
return ESP_OK;
|
||
}
|