403 lines
14 KiB
C
403 lines
14 KiB
C
#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;
|
||
}
|
||
}
|