完成初代的雏形设计

This commit is contained in:
Wang Beihong
2026-03-11 20:14:14 +08:00
commit 2f56316c18
63 changed files with 10594 additions and 0 deletions

584
main/status_web.c Normal file
View File

@@ -0,0 +1,584 @@
#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;
}