Files
edc-mcu/components/web_ctrl/web_ctrl.c
T

547 lines
20 KiB
C

/**
* @file web_ctrl.c
* @brief WiFi STA + HTTP 服务器 + 网页控制面板
*/
#include "web_ctrl.h"
#include "emm_v5.h"
#include "magnet.h"
#include "servo.h"
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "nvs_flash.h"
static const char *TAG = "WEB_CTRL";
/* ---- WiFi 配置 ---- */
#define WIFI_SSID "ICE"
#define WIFI_PASS "12345678"
#define WIFI_MAX_RETRY 5
#define WIFI_CONNECT_TIMEOUT_MS 30000
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static int s_retry_num = 0;
/* 保存 IP 供 /api/status 使用 */
static char s_ip_str[16] = "0.0.0.0";
/* ---- HTML 页面 ---- */
static const char INDEX_HTML[] = R"raw(<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>EDC 步进电机控制</title>
<style>
:root{
--bg:#f6f7f9;--card:#fff;--border:#e6e8ec;--text:#1f2329;--sub:#8a8f99;
--accent:#4f7cff;--accent-bg:#eef3ff;
--green:#34c38f;--green-bg:#e8f8f2;
--red:#f66;--red-bg:#feefef;
--amber:#f7b84b;--amber-bg:#fef6e8;
--radius:12px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
/* ---- 顶栏 ---- */
.header{background:var(--card);border-bottom:1px solid var(--border);padding:0 clamp(16px,4vw,32px);height:60px;display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;top:0;z-index:10}
.header h1{font-size:1.05rem;font-weight:600;letter-spacing:.02em;display:flex;align-items:center;gap:10px}
.header h1::before{content:'';width:10px;height:10px;border-radius:3px;background:var(--accent)}
.status-bar{display:flex;gap:8px;font-size:.8rem;color:var(--sub)}
.status-bar span{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);padding:5px 12px;border-radius:999px;white-space:nowrap}
.dot{width:7px;height:7px;border-radius:50%;background:var(--green)}
/* ---- 主布局 ---- */
.container{width:100%;max-width:1200px;margin:0 auto;padding:clamp(14px,3vw,28px) clamp(16px,4vw,32px) 40px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr));gap:14px}
/* ---- 卡片 ---- */
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
.card h2{font-size:.95rem;font-weight:600;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.card h2 .tag{width:8px;height:8px;border-radius:2px;background:var(--accent)}
.card h2 .tag.green{background:var(--green)}
.card h2 .tag.amber{background:var(--amber)}
.row{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;margin-bottom:10px}
.row label{min-width:52px;font-size:.82rem;color:var(--sub);font-weight:500}
input,select{flex:1;min-width:80px;padding:8px 12px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:.88rem;transition:border-color .15s,background .15s}
input:focus,select:focus{border-color:var(--accent);background:var(--card);outline:none}
input[type=range]{flex:2;padding:0;border:none;background:transparent;accent-color:var(--accent)}
.unit{font-size:.78rem;color:var(--sub)}
.servo-val{color:var(--accent);font-weight:600;min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
/* ---- 按钮 ---- */
.btn-row{display:flex;gap:8px;margin-top:14px}
button{flex:1;padding:10px 8px;border:none;border-radius:8px;font-size:.86rem;font-weight:600;cursor:pointer;transition:opacity .15s;white-space:nowrap}
button:hover{opacity:.85}
button:active{opacity:.7}
.btn-on{background:var(--green-bg);color:#0f9d6e}
.btn-off{background:var(--red-bg);color:#e04f4f}
.btn-go{background:var(--accent);color:#fff}
.btn-stop{background:var(--amber-bg);color:#d99413}
.btn-block{width:100%;margin-top:14px}
/* ---- 日志 ---- */
.log-card{margin-top:14px}
.log{font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:.78rem;max-height:180px;overflow-y:auto;color:var(--sub);line-height:1.8}
.log .ok{color:var(--text)}
.log .err{color:#e04f4f}
@media(max-width:560px){
.header{height:auto;padding:12px 16px;flex-direction:column;align-items:flex-start;gap:8px}
}
</style>
</head>
<body>
<div class="header">
<h1>EDC 步进电机控制</h1>
<div class="status-bar">
<span id="wifi"><i class="dot"></i>WiFi: --</span>
<span id="ip">IP: --</span>
</div>
</div>
<div class="container">
<div class="grid">
<div class="card">
<h2><i class="tag"></i>电机使能</h2>
<div class="row"><label>地址</label><input id="en_addr" type="number" value="1" min="1" max="247"></div>
<div class="btn-row">
<button class="btn-on" onclick="api('enable?addr='+v('en_addr')+'&state=1')">使能</button>
<button class="btn-off" onclick="api('enable?addr='+v('en_addr')+'&state=0')">关闭</button>
</div>
</div>
<div class="card">
<h2><i class="tag"></i>速度模式</h2>
<div class="row"><label>地址</label><input id="v_addr" type="number" value="1" min="1" max="247"></div>
<div class="row"><label>方向</label><select id="v_dir"><option value="0">CW 正转</option><option value="1">CCW 反转</option></select></div>
<div class="row"><label>速度</label><input id="v_vel" type="number" value="100" min="0" max="5000"><span class="unit">RPM</span></div>
<div class="row"><label>加速度</label><input id="v_acc" type="number" value="10" min="0" max="255"></div>
<div class="btn-row">
<button class="btn-go" onclick="api('velocity?addr='+v('v_addr')+'&dir='+v('v_dir')+'&vel='+v('v_vel')+'&acc='+v('v_acc'))">启动</button>
<button class="btn-stop" onclick="api('stop?addr='+v('v_addr'))">停止</button>
</div>
</div>
<div class="card">
<h2><i class="tag"></i>位置模式</h2>
<div class="row"><label>地址</label><input id="p_addr" type="number" value="1" min="1" max="247"></div>
<div class="row"><label>方向</label><select id="p_dir"><option value="0">CW 正转</option><option value="1">CCW 反转</option></select></div>
<div class="row"><label>速度</label><input id="p_vel" type="number" value="100" min="0" max="5000"><span class="unit">RPM</span></div>
<div class="row"><label>加速度</label><input id="p_acc" type="number" value="10" min="0" max="255"></div>
<div class="row"><label>脉冲数</label><input id="p_clk" type="number" value="2000" min="0"></div>
<div class="row"><label>模式</label><select id="p_raf"><option value="0">相对运动</option><option value="1">绝对运动</option></select></div>
<div class="btn-row">
<button class="btn-go" onclick="api('position?addr='+v('p_addr')+'&dir='+v('p_dir')+'&vel='+v('p_vel')+'&acc='+v('p_acc')+'&clk='+v('p_clk')+'&raf='+v('p_raf'))">启动</button>
<button class="btn-stop" onclick="api('stop?addr='+v('p_addr'))">停止</button>
</div>
</div>
<div class="card">
<h2><i class="tag green"></i>舵机控制</h2>
<div class="row">
<label>角度</label>
<input id="s_angle" type="range" value="90" min="0" max="180" step="1" oninput="document.getElementById('s_val').textContent=this.value+'°'">
<span class="servo-val" id="s_val">90°</span>
</div>
<button class="btn-go btn-block" onclick="api('servo?angle='+v('s_angle'))">转到指定角度</button>
<div class="btn-row">
<button class="btn-on" onclick="api('servo?pos=up')">上限</button>
<button class="btn-off" onclick="api('servo?pos=down')">下限</button>
</div>
</div>
<div class="card">
<h2><i class="tag amber"></i>电磁铁</h2>
<div class="btn-row">
<button class="btn-on" onclick="api('magnet?state=1')">吸合</button>
<button class="btn-off" onclick="api('magnet?state=0')">释放</button>
</div>
</div>
</div>
<div class="card log-card">
<h2><i class="tag green"></i>运行日志</h2>
<div class="log" id="log">等待指令...</div>
</div>
</div>
<script>
function v(id){return document.getElementById(id).value}
function api(cmd){
fetch('/api/'+cmd).then(r=>r.text()).then(t=>{
var l=document.getElementById('log');
l.innerHTML='<div class="ok">'+t+'</div>'+l.innerHTML;
while(l.children.length>50)l.removeChild(l.lastChild)
}).catch(function(e){
var l=document.getElementById('log');
l.innerHTML='<div class="err">请求失败</div>'+l.innerHTML
})
}
fetch('/api/status').then(function(r){return r.json()}).then(function(d){
document.getElementById('wifi').innerHTML='<i class="dot"></i>WiFi: '+d.ssid;
document.getElementById('ip').textContent='IP: '+d.ip
})
</script>
</body>
</html>)raw";
/* ============================================================
* WiFi 事件处理
* ============================================================ */
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *ev = (wifi_event_sta_disconnected_t *)data;
ESP_LOGW(TAG, "WiFi 断开 (reason=%d), 重试 %d/%d",
ev->reason, s_retry_num + 1, WIFI_MAX_RETRY);
if (s_retry_num < WIFI_MAX_RETRY) {
s_retry_num++;
esp_wifi_connect();
} else {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
snprintf(s_ip_str, sizeof(s_ip_str), IPSTR, IP2STR(&ev->ip_info.ip));
ESP_LOGI(TAG, "========================================");
ESP_LOGI(TAG, " WiFi 已连接!");
ESP_LOGI(TAG, " SSID: %s", WIFI_SSID);
ESP_LOGI(TAG, " IP: %s", s_ip_str);
ESP_LOGI(TAG, " 浏览器打开 http://%s", s_ip_str);
ESP_LOGI(TAG, "========================================");
s_retry_num = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
static void wifi_init_sta(void)
{
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t inst_any_id, inst_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, &inst_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, &inst_got_ip));
wifi_config_t wifi_cfg = {
.sta = {
.ssid = WIFI_SSID,
.password = WIFI_PASS,
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "正在连接 WiFi: %s ...", WIFI_SSID);
/* 等待连接成功或失败 (30 秒超时) */
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE, pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS));
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "WiFi 就绪 (IP: %s)", s_ip_str);
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGE(TAG, "WiFi 连接失败! 请检查 SSID/密码");
} else {
ESP_LOGE(TAG, "WiFi 连接超时 (%d秒)!", WIFI_CONNECT_TIMEOUT_MS / 1000);
}
/* 无论 WiFi 是否成功, 都继续启动 HTTP 服务器 */
}
/* ============================================================
* HTTP 查询字符串解析
* ============================================================ */
static int get_query_int(httpd_req_t *req, const char *key, int def)
{
char buf[128];
size_t len = httpd_req_get_url_query_len(req) + 1;
if (len > sizeof(buf)) len = sizeof(buf);
if (httpd_req_get_url_query_str(req, buf, len) == ESP_OK) {
char val[16];
if (httpd_query_key_value(buf, key, val, sizeof(val)) == ESP_OK) {
return atoi(val);
}
}
return def;
}
/* ============================================================
* HTTP 处理器
* ============================================================ */
static esp_err_t root_handler(httpd_req_t *req)
{
const char *html = INDEX_HTML;
size_t total = strlen(html);
size_t offset = 0;
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Connection", "close");
while (offset < total) {
size_t chunk = total - offset;
if (chunk > 1024) chunk = 1024;
esp_err_t err = httpd_resp_send_chunk(req, html + offset, (int)chunk);
if (err != ESP_OK) {
ESP_LOGE(TAG, "发送 HTML 失败: %d", err);
return err;
}
offset += chunk;
}
httpd_resp_send_chunk(req, NULL, 0); /* 结束分块 */
return ESP_OK;
}
static esp_err_t api_enable_handler(httpd_req_t *req)
{
int addr = get_query_int(req, "addr", 1);
int state = get_query_int(req, "state", 0);
Emm_V5_En_Control((uint8_t)addr, (bool)state, false);
char resp[64];
snprintf(resp, sizeof(resp), "电机 %d %s", addr, state ? "使能" : "关闭");
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_velocity_handler(httpd_req_t *req)
{
int addr = get_query_int(req, "addr", 1);
int dir = get_query_int(req, "dir", 0);
int vel = get_query_int(req, "vel", 100);
int acc = get_query_int(req, "acc", 10);
Emm_V5_Vel_Control((uint8_t)addr, (uint8_t)dir, (uint16_t)vel, (uint8_t)acc, false);
char resp[64];
snprintf(resp, sizeof(resp), "速度模式: 地址%d %s %dRPM acc=%d",
addr, dir ? "CCW" : "CW", vel, acc);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_position_handler(httpd_req_t *req)
{
int addr = get_query_int(req, "addr", 1);
int dir = get_query_int(req, "dir", 0);
int vel = get_query_int(req, "vel", 100);
int acc = get_query_int(req, "acc", 10);
int clk = get_query_int(req, "clk", 2000);
int raf = get_query_int(req, "raf", 0);
Emm_V5_Pos_Control((uint8_t)addr, (uint8_t)dir, (uint16_t)vel, (uint8_t)acc,
(uint32_t)clk, (bool)raf, false);
char resp[80];
snprintf(resp, sizeof(resp), "位置模式: 地址%d %s %dRPM %d脉冲 %s",
addr, dir ? "CCW" : "CW", vel, clk, raf ? "绝对" : "相对");
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_stop_handler(httpd_req_t *req)
{
int addr = get_query_int(req, "addr", 1);
Emm_V5_Stop_Now((uint8_t)addr, false);
char resp[32];
snprintf(resp, sizeof(resp), "电机 %d 已停止", addr);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_magnet_handler(httpd_req_t *req)
{
int state = get_query_int(req, "state", 0);
magnet_set((bool)state);
char resp[32];
snprintf(resp, sizeof(resp), "电磁铁 %s", state ? "吸合" : "释放");
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_status_handler(httpd_req_t *req)
{
char json[128];
wifi_ap_record_t ap;
esp_err_t err = esp_wifi_sta_get_ap_info(&ap);
snprintf(json, sizeof(json),
"{\"ssid\":\"%s\",\"ip\":\"%s\"}",
(err == ESP_OK) ? (char *)ap.ssid : "N/A",
s_ip_str);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t api_servo_handler(httpd_req_t *req)
{
char buf[128];
size_t len = httpd_req_get_url_query_len(req) + 1;
if (len > sizeof(buf)) len = sizeof(buf);
if (httpd_req_get_url_query_str(req, buf, len) == ESP_OK) {
char val[16];
if (httpd_query_key_value(buf, "pos", val, sizeof(val)) == ESP_OK) {
if (strcmp(val, "up") == 0) {
servo_set_up();
} else {
servo_set_down();
}
char resp[32];
snprintf(resp, sizeof(resp), "舵机: %s", val);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
if (httpd_query_key_value(buf, "angle", val, sizeof(val)) == ESP_OK) {
float angle = atof(val);
servo_set_angle(angle);
char resp[32];
snprintf(resp, sizeof(resp), "舵机: %.1f°", (double)angle);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
}
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 angle 或 pos 参数");
return ESP_FAIL;
}
/* favicon — 204 No Content, 避免浏览器反复请求 */
static esp_err_t favicon_handler(httpd_req_t *req)
{
httpd_resp_set_status(req, "204 No Content");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
static void http_server_init(void)
{
httpd_handle_t server = NULL;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.stack_size = 8192; /* 默认 4096 不够, 增大 */
cfg.max_uri_handlers = 16;
cfg.max_open_sockets = 4;
cfg.lru_purge_enable = true;
ESP_ERROR_CHECK(httpd_start(&server, &cfg));
httpd_uri_t uri_root = {
.uri = "/",
.method = HTTP_GET,
.handler = root_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_root);
httpd_uri_t uri_enable = {
.uri = "/api/enable",
.method = HTTP_GET,
.handler = api_enable_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_enable);
httpd_uri_t uri_velocity = {
.uri = "/api/velocity",
.method = HTTP_GET,
.handler = api_velocity_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_velocity);
httpd_uri_t uri_position = {
.uri = "/api/position",
.method = HTTP_GET,
.handler = api_position_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_position);
httpd_uri_t uri_stop = {
.uri = "/api/stop",
.method = HTTP_GET,
.handler = api_stop_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_stop);
httpd_uri_t uri_magnet = {
.uri = "/api/magnet",
.method = HTTP_GET,
.handler = api_magnet_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_magnet);
httpd_uri_t uri_status = {
.uri = "/api/status",
.method = HTTP_GET,
.handler = api_status_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_status);
httpd_uri_t uri_servo = {
.uri = "/api/servo",
.method = HTTP_GET,
.handler = api_servo_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_servo);
/* favicon.ico — 返回空, 避免 404 警告 */
httpd_uri_t uri_favicon = {
.uri = "/favicon.ico",
.method = HTTP_GET,
.handler = favicon_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &uri_favicon);
ESP_LOGI(TAG, "HTTP 服务器已启动 (端口 %d)", cfg.server_port);
}
/* ============================================================
* 公开接口
* ============================================================ */
void web_ctrl_init(void)
{
wifi_init_sta();
http_server_init();
}