Files
Smart-granary-code/main/status_web.c
2026-03-11 20:14:14 +08:00

585 lines
28 KiB
C
Raw 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 "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;
}