Files
AI_Printer/components/control_plane/src/rest_server.c

403 lines
14 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 "control_plane.h"
#include "rest_server_internal.h"
#include "esp_http_server.h"
#include "esp_log.h"
static const char *TAG = "rest_server";
static httpd_handle_t s_server;
static const char *s_web_index =
"<!doctype html>\n"
"<html lang=\"zh-CN\">\n"
"<head>\n"
" <meta charset=\"utf-8\">\n"
" <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
" <title>TQ Voice Console</title>\n"
" <style>\n"
" :root { color-scheme: light; }\n"
" body { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; margin: 16px; background:#f7f8fb; color:#1b2430; }\n"
" .box { max-width: 760px; margin: 0 auto; background:#fff; border:1px solid #dfe6ee; border-radius:12px; padding:16px; }\n"
" h1 { margin: 0 0 8px 0; font-size: 22px; }\n"
" p { margin: 6px 0 12px 0; color:#4b5d73; }\n"
" .row { display:flex; gap:8px; flex-wrap:wrap; margin: 8px 0; }\n"
" button { border:0; border-radius:8px; padding:10px 14px; cursor:pointer; background:#198754; color:#fff; font: inherit; min-width:220px; }\n"
" button.active { background:#d68910; }\n"
" button:disabled { background:#5f6f82; cursor:default; }\n"
" .small { font-size: 13px; color:#44566c; }\n"
" </style>\n"
"</head>\n"
"<body>\n"
" <div class=\"box\">\n"
" <h1>TQ Voice Console</h1>\n"
" <p>ESP32-S3 本地语音控制页。用于单轮语音对话Tap2Talk。</p>\n"
"\n"
" <div class=\"row\">\n"
" <button id=\"btnVoiceTalk\">点击开始单轮对话</button>\n"
" </div>\n"
" <div id=\"voiceState\" class=\"small\">语音状态:初始化中</div>\n"
" </div>\n"
"\n"
" <script>\n"
" const btnVoiceTalkEl = document.getElementById(\"btnVoiceTalk\");\n"
" const voiceStateEl = document.getElementById(\"voiceState\");\n"
"\n"
" let voiceTalkBusy = false;\n"
" let voiceStatusSyncing = false;\n"
"\n"
" function headers(withJson) {\n"
" const h = {};\n"
" if (withJson) h[\"Content-Type\"] = \"application/json\";\n"
" return h;\n"
" }\n"
"\n"
" function sleep(ms) {\n"
" return new Promise((resolve) => setTimeout(resolve, ms));\n"
" }\n"
"\n"
" async function callApi(path, method, body) {\n"
" const init = { method, headers: headers(body !== undefined) };\n"
" if (body !== undefined) init.body = JSON.stringify(body);\n"
" const r = await fetch(path, init);\n"
" const text = await r.text();\n"
" let data = text;\n"
" try { data = JSON.parse(text); } catch (_) {}\n"
" if (!r.ok) {\n"
" throw { status: r.status, data };\n"
" }\n"
" return data;\n"
" }\n"
"\n"
" function voiceRoundBusy(st) {\n"
" if (!st) return false;\n"
" const state = (st.voice_dialog_state || \"\").toLowerCase();\n"
" const voiceBusy = state === \"thinking\" || state === \"responding\";\n"
" const markerBusy = !!st.voice_marker_image_gen_running;\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" const printBusy = !!st.printer_busy || queueDepth > 0;\n"
" return voiceBusy || markerBusy || printBusy;\n"
" }\n"
"\n"
" function voiceStatusText(st) {\n"
" if (!st) return \"语音状态:未知\";\n"
" const state = (st.voice_dialog_state || \"\").toLowerCase();\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" if (!st.voice_session_active) return \"语音状态:会话未启动\";\n"
" if (!st.voice_ws_connected) return \"语音状态:会话断开,点击开始将自动重建\";\n"
" if (st.voice_tap_active) return \"语音状态:录音中\";\n"
" if (state === \"thinking\" || state === \"responding\") return \"语音状态:语音处理中\";\n"
" if (!!st.voice_marker_image_gen_running) return \"语音状态:图片生成中\";\n"
" if (!!st.printer_busy || queueDepth > 0) return \"语音状态:打印处理中\";\n"
" return \"语音状态:空闲,可开始下一轮\";\n"
" }\n"
"\n"
" function voiceErrorText(st) {\n"
" if (!st || st.voice_last_error === undefined || st.voice_last_error === null) return \"\";\n"
" const msg = String(st.voice_last_error).trim();\n"
" if (!msg || msg === \"-\") return \"\";\n"
" return \"最近错误:\" + msg;\n"
" }\n"
"\n"
" function voiceRoundBusyText(st) {\n"
" if (!st) return \"处理中,请等待\";\n"
" const state = (st.voice_dialog_state || \"\").toLowerCase();\n"
" if (state === \"thinking\" || state === \"responding\") return \"语音处理中,请等待\";\n"
" if (!!st.voice_marker_image_gen_running) return \"图片生成中,请等待\";\n"
" const queueDepth = Number(st.queue_depth || 0);\n"
" if (!!st.printer_busy || queueDepth > 0) return \"打印处理中,请等待\";\n"
" return \"处理中,请等待\";\n"
" }\n"
"\n"
" function setVoiceTalkButtonState(active) {\n"
" if (active) {\n"
" btnVoiceTalkEl.classList.add(\"active\");\n"
" btnVoiceTalkEl.textContent = \"点击取消本轮录音\";\n"
" } else {\n"
" btnVoiceTalkEl.classList.remove(\"active\");\n"
" btnVoiceTalkEl.textContent = \"点击开始单轮对话\";\n"
" }\n"
" }\n"
"\n"
" function syncVoiceTalkUi(st) {\n"
" const active = !!(st && st.voice_tap_active && st.voice_ws_connected && st.voice_started);\n"
" const busy = voiceRoundBusy(st);\n"
" let status = voiceStatusText(st);\n"
" const err = voiceErrorText(st);\n"
" if (err) status += \"\" + err;\n"
" if (voiceStateEl) voiceStateEl.textContent = status;\n"
"\n"
" if (busy) {\n"
" btnVoiceTalkEl.classList.remove(\"active\");\n"
" btnVoiceTalkEl.disabled = true;\n"
" btnVoiceTalkEl.textContent = voiceRoundBusyText(st);\n"
" return;\n"
" }\n"
" btnVoiceTalkEl.disabled = false;\n"
" setVoiceTalkButtonState(active);\n"
" }\n"
"\n"
" function voiceReady(st) {\n"
" return !!(st && st.voice_session_active && st.voice_ws_connected && st.voice_started);\n"
" }\n"
"\n"
" async function ensureVoiceSessionReady() {\n"
" let st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" if (voiceReady(st)) return st;\n"
"\n"
" await callApi(\"/v1/voice/session/start\", \"POST\", {});\n"
" const deadline = Date.now() + 10000;\n"
" while (Date.now() < deadline) {\n"
" await sleep(250);\n"
" st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" if (voiceReady(st)) return st;\n"
" }\n"
" throw new Error(\"voice session is not ready, please retry\");\n"
" }\n"
"\n"
" async function refreshVoiceStatus() {\n"
" if (voiceStatusSyncing) return null;\n"
" voiceStatusSyncing = true;\n"
" try {\n"
" const st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" } finally {\n"
" voiceStatusSyncing = false;\n"
" }\n"
" }\n"
"\n"
" async function beginVoiceTalk() {\n"
" if (voiceTalkBusy) {\n"
" const st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" }\n"
" voiceTalkBusy = true;\n"
" try {\n"
" let st = await ensureVoiceSessionReady();\n"
" if (st && st.voice_tap_active) {\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" }\n"
" await callApi(\"/v1/voice/tap/start\", \"POST\", {});\n"
" st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" } finally {\n"
" voiceTalkBusy = false;\n"
" }\n"
" }\n"
"\n"
" async function endVoiceTalk() {\n"
" if (voiceTalkBusy) {\n"
" const st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" }\n"
" voiceTalkBusy = true;\n"
" try {\n"
" let st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" if (!(st && st.voice_tap_active)) return st;\n"
" await callApi(\"/v1/voice/tap/cancel\", \"POST\", {});\n"
" st = await callApi(\"/v1/voice/status\", \"GET\");\n"
" syncVoiceTalkUi(st);\n"
" return st;\n"
" } finally {\n"
" voiceTalkBusy = false;\n"
" }\n"
" }\n"
"\n"
" btnVoiceTalkEl.addEventListener(\"click\", async () => {\n"
" try {\n"
" const st = await refreshVoiceStatus();\n"
" if (!st) return;\n"
" if (voiceRoundBusy(st)) return;\n"
" if (st && st.voice_tap_active) {\n"
" await endVoiceTalk();\n"
" } else {\n"
" await beginVoiceTalk();\n"
" }\n"
" } catch (e) {\n"
" let msg = \"语音状态:请求失败\";\n"
" if (e && e.data && typeof e.data === \"object\" && e.data.error) {\n"
" msg += \"\" + String(e.data.error);\n"
" }\n"
" voiceStateEl.textContent = msg;\n"
" }\n"
" });\n"
"\n"
" setVoiceTalkButtonState(false);\n"
" (async () => {\n"
" try { await refreshVoiceStatus(); } catch (_) {}\n"
" })();\n"
"\n"
" setInterval(() => {\n"
" refreshVoiceStatus().catch(() => {\n"
" if (voiceStateEl) voiceStateEl.textContent = \"语音状态:状态获取失败\";\n"
" });\n"
" }, 1500);\n"
" </script>\n"
"</body>\n"
"</html>\n"
;
static esp_err_t index_get(httpd_req_t *req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, s_web_index, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t favicon_get(httpd_req_t *req) {
httpd_resp_set_status(req, "204 No Content");
return httpd_resp_send(req, NULL, 0);
}
esp_err_t rest_server_start(void) {
if (s_server != NULL) {
return ESP_OK;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = CONFIG_TQ_HTTP_PORT;
config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 20;
config.stack_size = 10240;
#if CONFIG_FREERTOS_UNICORE
config.core_id = 0;
#else
config.core_id = 1;
#endif
esp_err_t err = httpd_start(&s_server, &config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "httpd_start failed: %s", esp_err_to_name(err));
return err;
}
httpd_uri_t index = {
.uri = "/",
.method = HTTP_GET,
.handler = index_get,
};
httpd_register_uri_handler(s_server, &index);
httpd_uri_t favicon = {
.uri = "/favicon.ico",
.method = HTTP_GET,
.handler = favicon_get,
};
httpd_register_uri_handler(s_server, &favicon);
httpd_uri_t health = {
.uri = "/v1/health",
.method = HTTP_GET,
.handler = rest_server_health_get,
};
httpd_register_uri_handler(s_server, &health);
httpd_uri_t connect = {
.uri = "/v1/printer/connect",
.method = HTTP_POST,
.handler = rest_server_connect_post,
};
httpd_register_uri_handler(s_server, &connect);
httpd_uri_t disconnect = {
.uri = "/v1/printer/disconnect",
.method = HTTP_POST,
.handler = rest_server_disconnect_post,
};
httpd_register_uri_handler(s_server, &disconnect);
httpd_uri_t status = {
.uri = "/v1/printer/status",
.method = HTTP_GET,
.handler = rest_server_status_get,
};
httpd_register_uri_handler(s_server, &status);
httpd_uri_t voice_status = {
.uri = "/v1/voice/status",
.method = HTTP_GET,
.handler = rest_server_voice_status_get,
};
httpd_register_uri_handler(s_server, &voice_status);
httpd_uri_t voice_session_start = {
.uri = "/v1/voice/session/start",
.method = HTTP_POST,
.handler = rest_server_voice_session_start_post,
};
httpd_register_uri_handler(s_server, &voice_session_start);
httpd_uri_t voice_session_stop = {
.uri = "/v1/voice/session/stop",
.method = HTTP_POST,
.handler = rest_server_voice_session_stop_post,
};
httpd_register_uri_handler(s_server, &voice_session_stop);
httpd_uri_t voice_tap_start = {
.uri = "/v1/voice/tap/start",
.method = HTTP_POST,
.handler = rest_server_voice_tap_start_post,
};
httpd_register_uri_handler(s_server, &voice_tap_start);
httpd_uri_t voice_tap_cancel = {
.uri = "/v1/voice/tap/cancel",
.method = HTTP_POST,
.handler = rest_server_voice_tap_cancel_post,
};
httpd_register_uri_handler(s_server, &voice_tap_cancel);
httpd_uri_t print_raster = {
.uri = "/v1/print/raster",
.method = HTTP_POST,
.handler = rest_server_print_raster_post,
};
httpd_register_uri_handler(s_server, &print_raster);
httpd_uri_t print_image = {
.uri = "/v1/print/image",
.method = HTTP_POST,
.handler = rest_server_print_image_post,
};
httpd_register_uri_handler(s_server, &print_image);
httpd_uri_t print_text = {
.uri = "/v1/print/text",
.method = HTTP_POST,
.handler = rest_server_print_text_post,
};
httpd_register_uri_handler(s_server, &print_text);
httpd_uri_t jobs = {
.uri = "/v1/jobs",
.method = HTTP_GET,
.handler = rest_server_jobs_get,
};
httpd_register_uri_handler(s_server, &jobs);
httpd_uri_t job = {
.uri = "/v1/jobs/*",
.method = HTTP_GET,
.handler = rest_server_job_get,
};
httpd_register_uri_handler(s_server, &job);
ESP_LOGI(TAG, "REST server started on port %d", CONFIG_TQ_HTTP_PORT);
return ESP_OK;
}
void rest_server_stop(void) {
if (s_server != NULL) {
httpd_stop(s_server);
s_server = NULL;
}
}