diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 60213ce2..8deb5d6a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -266,7 +266,7 @@ idf_component_register( ## 参考项目 robOS 是 TianShanOS 的前身项目,包含已验证的硬件驱动和控制逻辑: -- **仓库地址**:https://github.com/thomas-hiddenpeak/robOS +- **仓库地址**:https://github.com/RMinte-AI/TianshanOS - **主要分支**:`ThorPlusBattery`(电池/电压保护功能) - **本地路径**:`/Users/thomas/rm01/robOS` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a63db01b..0374bc76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,18 +54,35 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Fetch matching ESP-IDF cJSON sources for host tests + - name: Fetch matching ESP-IDF cJSON and MbedTLS sources for host tests run: | export IDF_PATH="$RUNNER_TEMP/runtime-idf" echo "IDF_PATH=$IDF_PATH" >> "$GITHUB_ENV" git clone --depth 1 --branch "$IDF_VERSION" --filter=blob:none --sparse https://github.com/espressif/esp-idf.git "$IDF_PATH" - git -C "$IDF_PATH" sparse-checkout set components/json - git -C "$IDF_PATH" submodule update --init --depth 1 components/json/cJSON + git -C "$IDF_PATH" sparse-checkout set components/json components/mbedtls + git -C "$IDF_PATH" submodule update --init --recursive --depth 1 components/json/cJSON components/mbedtls/mbedtls - uses: actions/setup-node@v4 with: node-version: '22' - run: bash tests/runtime/run_all.sh + - run: bash tests/ws_subscriptions/run_operation_adapter.sh - run: bash tests/fan_monitor/run_host.sh + - name: Fan output and decay regressions + run: | + python3 tests/fan_review/run_host.py --check --cjson-dir "$IDF_PATH/components/json/cJSON" --output "$RUNNER_TEMP/fan-review.jsonl" + node tests/fan_review/test_ui.cjs "$RUNNER_TEMP/fan-review.jsonl" + python3 tests/fan_decay/run_host.py --csv "$RUNNER_TEMP/fan-decay.csv" + node tests/fan_decay/test_ui.cjs + - name: CSR subject signing and parsing regression + run: | + cmake -S "$IDF_PATH/components/mbedtls/mbedtls" -B "$RUNNER_TEMP/mbedtls" -DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF + cmake --build "$RUNNER_TEMP/mbedtls" --parallel 2 + cc -std=c11 -g -fsanitize=address,undefined -Itests/certificate/stubs -Icomponents/ts_cert/include \ + -I"$IDF_PATH/components/mbedtls/mbedtls/include" \ + tests/certificate/test_subject.c components/ts_cert/src/ts_cert_subject.c \ + "$RUNNER_TEMP/mbedtls/library/libmbedx509.a" "$RUNNER_TEMP/mbedtls/library/libmbedcrypto.a" \ + -lm -o "$RUNNER_TEMP/csr-subject" + "$RUNNER_TEMP/csr-subject" build: name: Build ESP32-S3 Firmware @@ -90,18 +107,23 @@ jobs: with: path: | ~/esp/esp-idf - ~/.espressif - key: esp-idf-${{ env.IDF_VERSION }}-${{ runner.os }} + ~/.espressif/tools + ~/.espressif/dist + # Python virtual environments embed runner/interpreter paths. Cache + # reusable tools only; create the active Python environment per run. + key: esp-idf-tools-${{ env.IDF_VERSION }}-${{ runner.os }}-v1 restore-keys: | - esp-idf-${{ env.IDF_VERSION }}- + esp-idf-tools-${{ env.IDF_VERSION }}-${{ runner.os }}- - - name: Install ESP-IDF - if: steps.cache-idf.outputs.cache-hit != 'true' + - name: Install ESP-IDF tools and matching Python environment run: | mkdir -p ~/esp - cd ~/esp - git clone -b ${{ env.IDF_VERSION }} --depth 1 --recursive https://github.com/espressif/esp-idf.git - cd esp-idf + if [ ! -d ~/esp/esp-idf ]; then + git clone -b ${{ env.IDF_VERSION }} --depth 1 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf + fi + cd ~/esp/esp-idf + # Cached tools may have been installed with a different Python. + # install.sh creates/checks the environment for the active Python. ./install.sh ${{ env.TARGET }} - name: Setup ESP-IDF environment diff --git a/.gitignore b/.gitignore index 37986b2e..7d658abd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build-root-reset/ !build/www.bin !build/ota_data_initial.bin !build/flasher_args.json +!build/firmware-manifest.json !build/bootloader/ build/bootloader/* !build/bootloader/bootloader.bin @@ -63,3 +64,7 @@ frontend_emoji_list.md # Local regression dependencies tests/prompts/node_modules/ + +# Local browser traces and validation/build snapshots +.playwright-cli/ +output/ diff --git a/README.md b/README.md index 190ff68f..5d506118 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [English](README_EN.md) | [中文](README.md) -[![Build Status](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml) +[![Build Status](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml) ![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) -[![License](https://img.shields.io/github/license/thomas-hiddenpeak/TianshanOS)](LICENSE) +[![License](https://img.shields.io/github/license/RMinte-AI/TianshanOS)](LICENSE) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) ![C](https://img.shields.io/badge/C-99-blue?logo=c) ![ESP32-S3](https://img.shields.io/badge/ESP32--S3-supported-blue?logo=espressif) @@ -94,9 +94,12 @@ TianshanOS/ ### 快速开始 +本地最新 **0.6.1** 固件位于 `build/`:`TianShanOS.bin`、`www.bin`、`bootloader/bootloader.bin`、`partition_table/partition-table.bin`、`ota_data_initial.bin`。 +`build/firmware-manifest.json` 记录版本、构建标识和文件 SHA-256。刷写时读取同目录的 `flasher_args.json`,不要混用历史构建目录中的镜像。 + ```bash # 克隆项目 -git clone https://github.com/thomas-hiddenpeak/TianshanOS.git +git clone https://github.com/RMinte-AI/TianshanOS.git cd TianshanOS # 设置 ESP-IDF 环境 @@ -117,7 +120,7 @@ idf.py -p /dev/ttyACM0 flash monitor ### 预编译固件 -从 [Releases](https://github.com/thomas-hiddenpeak/TianshanOS/releases/latest) 下载预编译固件,使用 esptool 烧录: +从 [Releases](https://github.com/RMinte-AI/TianshanOS/releases/latest) 下载预编译固件,使用 esptool 烧录: ```bash esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \ diff --git a/README_EN.md b/README_EN.md index a5baec2d..b5a15201 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,10 +2,10 @@ [English](README_EN.md) | [中文](README.md) -[![Build Status](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/thomas-hiddenpeak/TianshanOS/actions/workflows/build.yml) +[![Build Status](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml) ![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) -[![Release](https://img.shields.io/github/v/release/thomas-hiddenpeak/TianshanOS)](https://github.com/thomas-hiddenpeak/TianshanOS/releases/latest) -[![License](https://img.shields.io/github/license/thomas-hiddenpeak/TianshanOS)](LICENSE) +[![Release](https://img.shields.io/github/v/release/RMinte-AI/TianshanOS)](https://github.com/RMinte-AI/TianshanOS/releases/latest) +[![License](https://img.shields.io/github/license/RMinte-AI/TianshanOS)](LICENSE) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) ![C](https://img.shields.io/badge/C-99-blue?logo=c) ![ESP32-S3](https://img.shields.io/badge/ESP32--S3-supported-blue?logo=espressif) @@ -87,9 +87,12 @@ TianshanOS/ ### Quick Start +The latest local **0.6.1** firmware is under `build/`: `TianShanOS.bin`, `www.bin`, `bootloader/bootloader.bin`, `partition_table/partition-table.bin`, and `ota_data_initial.bin`. +`build/firmware-manifest.json` records the version, build identifier, and file SHA-256 values. Use `flasher_args.json` from the same directory; do not mix images from historical build directories. + ```bash # Clone the repository -git clone https://github.com/thomas-hiddenpeak/TianshanOS.git +git clone https://github.com/RMinte-AI/TianshanOS.git cd TianshanOS # Set up ESP-IDF environment diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin index 70213870..a75f6292 100644 Binary files a/build/TianShanOS.bin and b/build/TianShanOS.bin differ diff --git a/build/bootloader/bootloader.bin b/build/bootloader/bootloader.bin index 3b6b374d..1aa132c6 100644 Binary files a/build/bootloader/bootloader.bin and b/build/bootloader/bootloader.bin differ diff --git a/build/firmware-manifest.json b/build/firmware-manifest.json new file mode 100644 index 00000000..e5942cbc --- /dev/null +++ b/build/firmware-manifest.json @@ -0,0 +1,33 @@ +{ + "version": "0.6.1", + "build_version": "0.6.1+73069d24d.10090811", + "idf_version": "5.5.2", + "source_base_commit": "73069d24da043a124d89028d25a5e4659d736652", + "source_inputs_sha256": "62e8ba896b9dff39133cecebf677da7c662b850d18981bd1323c0b053e7b1f2d", + "files": { + "TianShanOS.bin": { + "bytes": 2202336, + "sha256": "15f99d11092b91255d8fa24b632316b217d6efeb3acc3e20ed290e0e9fd54378" + }, + "www.bin": { + "bytes": 3145728, + "sha256": "feb4bc6f737aff611e534ca439990c99233b7a8879485d65a6e47e82bf075111" + }, + "ota_data_initial.bin": { + "bytes": 8192, + "sha256": "7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f" + }, + "flasher_args.json": { + "bytes": 1169, + "sha256": "5288d74c7e3ee9e0369f93f9418802ad2b455159cae42f6261c29be818034cc9" + }, + "bootloader/bootloader.bin": { + "bytes": 22496, + "sha256": "ac6d47f0b046fe2cb91b0bc86f9924885c8ac96d20da87b4d9841ec8c14ee3d8" + }, + "partition_table/partition-table.bin": { + "bytes": 3072, + "sha256": "973dfb7f44bdd42d525e53fcc1eb06ac5745687dddc1c73223715df841d785e5" + } + } +} diff --git a/build/www.bin b/build/www.bin index 418c5986..8479d824 100644 Binary files a/build/www.bin and b/build/www.bin differ diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 48233dfe..96f8a0a4 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -20,6 +20,8 @@ */ #include "ts_api.h" +#include "ts_action_filter.h" +#include "ts_api_service.h" #include "ts_automation.h" #include "ts_variable.h" #include "ts_rule_engine.h" @@ -640,11 +642,10 @@ static esp_err_t service_request(const cJSON *params, ts_api_result_t *result, b ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "command_id required"); return ESP_ERR_INVALID_ARG; } + if (stop || cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "verify"))) + return ts_api_service_control(params, result, stop ? TS_SERVICE_STOP : TS_SERVICE_VERIFY); ts_ssh_service_status_t state = {0}; - esp_err_t ret = stop ? ts_ssh_service_stop(id->valuestring, &state) - : (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "verify")) - ? ts_ssh_service_query(id->valuestring, &state) - : ts_ssh_service_cached(id->valuestring, &state)); + esp_err_t ret = ts_ssh_service_cached(id->valuestring, &state); cJSON *data = cJSON_CreateObject(); if (!data) return ESP_ERR_NO_MEM; @@ -654,6 +655,10 @@ static esp_err_t service_request(const cJSON *params, ts_api_result_t *result, b cJSON_AddNumberToObject(data, "confirmed_ms", state.confirmed_ms); cJSON_AddNumberToObject(data, "generation", state.generation); cJSON_AddBoolToObject(data, "busy", state.busy); + cJSON_AddNumberToObject(data, "operation_id", state.operation_id); + cJSON_AddStringToObject(data, "operation_phase", state.operation_phase); + cJSON_AddStringToObject(data, "operation_kind", state.operation_kind); + cJSON_AddNumberToObject(data, "operation_error", state.operation_error); if (ret == ESP_OK) ts_api_result_ok(result, data); else { @@ -1821,7 +1826,8 @@ static esp_err_t api_automation_actions_list(const cJSON *params, ts_api_result_ /** * @brief automation.actions.add - Add a new action template */ -static esp_err_t api_automation_actions_add(const cJSON *params, ts_api_result_t *result) +static esp_err_t api_automation_actions_write(const cJSON *params, ts_api_result_t *result, + bool update) { cJSON *id = cJSON_GetObjectItem(params, "id"); cJSON *name = cJSON_GetObjectItem(params, "name"); @@ -1949,6 +1955,11 @@ static esp_err_t api_automation_actions_add(const cJSON *params, ts_api_result_t tpl.action.led.qr_ecc = qr_ecc->valuestring[0]; /* L/M/Q/H */ } cJSON *filter = cJSON_GetObjectItem(led, "filter"); + if (ts_action_filter_decode(cJSON_GetObjectItemCaseSensitive(led, "filter_params"), + &tpl.action.led.filter_params) != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid LED filter parameters"); + return ESP_ERR_INVALID_ARG; + } if (filter && cJSON_IsString(filter)) { strncpy(tpl.action.led.filter, filter->valuestring, sizeof(tpl.action.led.filter) - 1); @@ -2137,25 +2148,38 @@ static esp_err_t api_automation_actions_add(const cJSON *params, ts_api_result_t break; } - esp_err_t ret = ts_action_template_add(&tpl); + esp_err_t ret = update ? ts_action_template_update(tpl.id, &tpl) : ts_action_template_add(&tpl); if (ret == ESP_OK) { result->code = TS_API_OK; - result->message = strdup("Action template created"); + result->message = strdup(update ? "Action template updated" : "Action template created"); } else if (ret == ESP_ERR_NO_MEM) { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Max templates reached"); } else if (ret == ESP_ERR_INVALID_STATE) { - result->code = TS_API_ERR_INVALID_ARG; - result->message = strdup("Template ID already exists"); + result->code = update ? TS_API_ERR_BUSY : TS_API_ERR_INVALID_ARG; + result->message = strdup(update ? "service_delete_protected" : "Template ID already exists"); + } else if (ret == ESP_ERR_NOT_FOUND) { + result->code = TS_API_ERR_NOT_FOUND; + result->message = strdup("Template not found"); } else { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to create template"); + result->message = strdup(update ? "Failed to update template" : "Failed to create template"); } return ESP_OK; } +static esp_err_t api_automation_actions_add(const cJSON *params, ts_api_result_t *result) +{ + return api_automation_actions_write(params, result, false); +} + +static esp_err_t api_automation_actions_update(const cJSON *params, ts_api_result_t *result) +{ + return api_automation_actions_write(params, result, true); +} + /** * @brief automation.actions.get - Get a single action template by ID */ @@ -2184,6 +2208,7 @@ static esp_err_t api_automation_actions_get(const cJSON *params, ts_api_result_t cJSON_AddStringToObject(result->data, "description", tpl.description); cJSON_AddStringToObject(result->data, "type", action_type_to_string(tpl.action.type)); cJSON_AddBoolToObject(result->data, "enabled", tpl.enabled); + cJSON_AddBoolToObject(result->data, "async", tpl.async); cJSON_AddNumberToObject(result->data, "delay_ms", tpl.action.delay_ms); /* Add type-specific data */ @@ -2249,6 +2274,12 @@ static esp_err_t api_automation_actions_get(const cJSON *params, ts_api_result_t if (tpl.action.led.filter[0]) { cJSON_AddStringToObject(led, "filter", tpl.action.led.filter); } + if (!ts_action_filter_encode(&tpl.action.led.filter_params, led)) { + cJSON_Delete(result->data); + result->data = NULL; + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize LED filter parameters"); + return ESP_ERR_NO_MEM; + } cJSON_AddBoolToObject(led, "center", tpl.action.led.center); cJSON_AddBoolToObject(led, "loop", tpl.action.led.loop); if (tpl.action.led.scroll[0]) { @@ -2323,6 +2354,9 @@ static esp_err_t api_automation_actions_delete(const cJSON *params, ts_api_resul } else if (ret == ESP_ERR_NOT_FOUND) { result->code = TS_API_ERR_NOT_FOUND; result->message = strdup("Template not found"); + } else if (ret == ESP_ERR_INVALID_STATE) { + result->code = TS_API_ERR_BUSY; + result->message = strdup("service_delete_protected"); } else { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Failed to delete template"); @@ -4087,6 +4121,21 @@ static cJSON *action_template_to_export_json(const ts_action_template_t *tpl) case TS_AUTO_ACT_LED: { cJSON *led = cJSON_AddObjectToObject(obj, "led"); cJSON_AddStringToObject(led, "device", tpl->action.led.device); + const char *ctrl_type = "fill"; + switch (tpl->action.led.ctrl_type) { + case TS_LED_CTRL_EFFECT: ctrl_type = "effect"; break; + case TS_LED_CTRL_BRIGHTNESS: ctrl_type = "brightness"; break; + case TS_LED_CTRL_OFF: ctrl_type = "off"; break; + case TS_LED_CTRL_TEXT: ctrl_type = "text"; break; + case TS_LED_CTRL_IMAGE: ctrl_type = "image"; break; + case TS_LED_CTRL_QRCODE: ctrl_type = "qrcode"; break; + case TS_LED_CTRL_FILTER: ctrl_type = "filter"; break; + case TS_LED_CTRL_FILTER_STOP: ctrl_type = "filter_stop"; break; + case TS_LED_CTRL_TEXT_STOP: ctrl_type = "text_stop"; break; + default: break; + } + cJSON_AddStringToObject(led, "ctrl_type", ctrl_type); + cJSON_AddNumberToObject(led, "index", tpl->action.led.index); char color_str[8]; snprintf(color_str, sizeof(color_str), "#%02X%02X%02X", tpl->action.led.r, tpl->action.led.g, tpl->action.led.b); @@ -4096,6 +4145,24 @@ static cJSON *action_template_to_export_json(const ts_action_template_t *tpl) cJSON_AddStringToObject(led, "effect", tpl->action.led.effect); } cJSON_AddNumberToObject(led, "duration_ms", tpl->action.led.duration_ms); + cJSON_AddNumberToObject(led, "speed", tpl->action.led.speed); + cJSON_AddStringToObject(led, "text", tpl->action.led.text); + cJSON_AddStringToObject(led, "font", tpl->action.led.font); + cJSON_AddStringToObject(led, "image_path", tpl->action.led.image_path); + cJSON_AddStringToObject(led, "qr_text", tpl->action.led.qr_text); + char ecc[2] = {tpl->action.led.qr_ecc, '\0'}; + cJSON_AddStringToObject(led, "qr_ecc", ecc); + cJSON_AddStringToObject(led, "filter", tpl->action.led.filter); + if (!ts_action_filter_encode(&tpl->action.led.filter_params, led)) { + cJSON_Delete(obj); + return NULL; + } + cJSON_AddBoolToObject(led, "center", tpl->action.led.center); + cJSON_AddBoolToObject(led, "loop", tpl->action.led.loop); + cJSON_AddStringToObject(led, "scroll", tpl->action.led.scroll); + cJSON_AddStringToObject(led, "align", tpl->action.led.align); + cJSON_AddNumberToObject(led, "x", tpl->action.led.x); + cJSON_AddNumberToObject(led, "y", tpl->action.led.y); break; } case TS_AUTO_ACT_LOG: { @@ -4148,9 +4215,14 @@ static esp_err_t api_automation_actions_export(const cJSON *params, ts_api_resul } // Build export JSON + cJSON *template_json = action_template_to_export_json(&tpl); + if (!template_json) { + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to serialize action template"); + return ESP_ERR_NO_MEM; + } cJSON *export_json = cJSON_CreateObject(); cJSON_AddStringToObject(export_json, "type", "action_template"); - cJSON_AddItemToObject(export_json, "template", action_template_to_export_json(&tpl)); + cJSON_AddItemToObject(export_json, "template", template_json); char *json_str = cJSON_PrintUnformatted(export_json); cJSON_Delete(export_json); @@ -4647,6 +4719,14 @@ esp_err_t ts_api_automation_register(void) .requires_auth = true, }; ts_api_register(&ep_actions_add); + ts_api_endpoint_t ep_actions_update = { + .name = "automation.actions.update", + .description = "Update an existing action template", + .category = TS_API_CAT_SYSTEM, + .handler = api_automation_actions_update, + .requires_auth = true, + }; + ts_api_register(&ep_actions_update); ts_api_endpoint_t ep_actions_delete = { .name = "automation.actions.delete", diff --git a/components/ts_api/src/ts_api_cert.c b/components/ts_api/src/ts_api_cert.c index d1e02b03..595e68de 100644 --- a/components/ts_api/src/ts_api_cert.c +++ b/components/ts_api/src/ts_api_cert.c @@ -17,6 +17,7 @@ #include "ts_api.h" #include "ts_cert.h" +#include "ts_cert_subject.h" #include "ts_https.h" #include "ts_log.h" #include @@ -240,7 +241,7 @@ static esp_err_t api_cert_generate_keypair(const cJSON *params, ts_api_result_t * Params: * { * "device_id": "TIANSHAN-RM01-0001", // optional, uses config default - * "organization": "HiddenPeak Labs", // optional + * "organization": "My Company", // optional * "org_unit": "Device" // optional * } * @@ -253,6 +254,28 @@ static esp_err_t api_cert_generate_keypair(const cJSON *params, ts_api_result_t static esp_err_t api_cert_generate_csr(const cJSON *params, ts_api_result_t *result) { TS_LOGI(TAG, "API: cert.generate_csr called"); + const cJSON *device_id_obj = cJSON_GetObjectItem(params, "device_id"); + const cJSON *org_obj = cJSON_GetObjectItem(params, "organization"); + const cJSON *ou_obj = cJSON_GetObjectItem(params, "org_unit"); + if ((device_id_obj && !cJSON_IsString(device_id_obj)) || + (org_obj && !cJSON_IsString(org_obj)) || (ou_obj && !cJSON_IsString(ou_obj))) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "CSR fields must be strings"); + return ESP_ERR_INVALID_ARG; + } + ts_cert_csr_opts_t opts = { + .device_id = device_id_obj && device_id_obj->valuestring[0] + ? device_id_obj->valuestring : TS_CERT_DEFAULT_DEVICE_ID, + .organization = org_obj ? org_obj->valuestring : NULL, + .org_unit = ou_obj ? ou_obj->valuestring : NULL, + }; + char subject[TS_CERT_SUBJECT_CAPACITY]; + esp_err_t validation = ts_cert_build_subject(opts.device_id, opts.organization, + opts.org_unit, subject, sizeof(subject)); + if (validation != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, + "CSR device ID must fit 63 UTF-8 bytes and encoded subject must fit 255 bytes"); + return validation; + } // 检查是否有密钥 if (!ts_cert_has_keypair()) { @@ -275,16 +298,7 @@ static esp_err_t api_cert_generate_csr(const cJSON *params, ts_api_result_t *res esp_err_t ret; // 如果提供了参数,使用自定义选项 - const cJSON *device_id_obj = cJSON_GetObjectItem(params, "device_id"); - const cJSON *org_obj = cJSON_GetObjectItem(params, "organization"); - const cJSON *ou_obj = cJSON_GetObjectItem(params, "org_unit"); - if (device_id_obj || org_obj || ou_obj) { - ts_cert_csr_opts_t opts = {0}; - opts.device_id = cJSON_IsString(device_id_obj) ? device_id_obj->valuestring : NULL; - opts.organization = cJSON_IsString(org_obj) ? org_obj->valuestring : NULL; - opts.org_unit = cJSON_IsString(ou_obj) ? ou_obj->valuestring : NULL; - ret = ts_cert_generate_csr(&opts, csr_pem, &csr_len); } else { // 使用默认选项 diff --git a/components/ts_api/src/ts_api_device.c b/components/ts_api/src/ts_api_device.c index dcd50026..dd6708d2 100644 --- a/components/ts_api/src/ts_api_device.c +++ b/components/ts_api/src/ts_api_device.c @@ -224,10 +224,13 @@ static esp_err_t api_device_fan_status(const cJSON *params, ts_api_result_t *res cJSON *fan = cJSON_CreateObject(); cJSON_AddNumberToObject(fan, "id", i); cJSON_AddStringToObject(fan, "mode", fan_mode_to_str(status.mode)); - cJSON_AddNumberToObject(fan, "duty", status.duty_percent); + cJSON_AddBoolToObject(fan, "duty_valid", status.duty_valid); + if (status.duty_valid) cJSON_AddNumberToObject(fan, "duty", status.duty_percent); + else cJSON_AddNullToObject(fan, "duty"); cJSON_AddNumberToObject(fan, "rpm", status.rpm); cJSON_AddNumberToObject(fan, "temp", status.temp / 10.0); - cJSON_AddBoolToObject(fan, "running", status.is_running); + if (status.duty_valid) cJSON_AddBoolToObject(fan, "running", status.is_running); + else cJSON_AddNullToObject(fan, "running"); cJSON_AddNumberToObject(fan, "control_temperature", status.control_temp / 10.0); cJSON_AddNumberToObject(fan, "guard_temperature", status.guard_temp / 10.0); cJSON_AddNumberToObject(fan, "predicted_temperature", status.predicted_temp / 10.0); diff --git a/components/ts_api/src/ts_api_fan.c b/components/ts_api/src/ts_api_fan.c index af8210fb..e8e6ba93 100644 --- a/components/ts_api/src/ts_api_fan.c +++ b/components/ts_api/src/ts_api_fan.c @@ -58,12 +58,15 @@ static cJSON *status_to_json(ts_fan_id_t fan_id, const ts_fan_status_t *status) cJSON *obj = cJSON_CreateObject(); cJSON_AddNumberToObject(obj, "id", fan_id); cJSON_AddStringToObject(obj, "mode", mode_to_string(status->mode)); - cJSON_AddNumberToObject(obj, "duty", status->duty_percent); + cJSON_AddBoolToObject(obj, "duty_valid", status->duty_valid); + if (status->duty_valid) cJSON_AddNumberToObject(obj, "duty", status->duty_percent); + else cJSON_AddNullToObject(obj, "duty"); cJSON_AddNumberToObject(obj, "target_duty", status->target_duty); cJSON_AddNumberToObject(obj, "rpm", status->rpm); cJSON_AddNumberToObject(obj, "temperature", status->temp / 10.0); cJSON_AddBoolToObject(obj, "enabled", status->enabled); - cJSON_AddBoolToObject(obj, "running", status->is_running); + if (status->duty_valid) cJSON_AddBoolToObject(obj, "running", status->is_running); + else cJSON_AddNullToObject(obj, "running"); cJSON_AddBoolToObject(obj, "fault", status->fault); cJSON_AddNumberToObject(obj, "control_temperature", status->control_temp / 10.0); cJSON_AddNumberToObject(obj, "guard_temperature", status->guard_temp / 10.0); @@ -173,10 +176,13 @@ static esp_err_t api_fan_set(const cJSON *params, ts_api_result_t *result) return ret; } - cJSON *data = cJSON_CreateObject(); - cJSON_AddNumberToObject(data, "id", fan_id); - cJSON_AddNumberToObject(data, "duty", duty); - cJSON_AddStringToObject(data, "mode", "manual"); + ts_fan_status_t status; + ret = ts_fan_get_status(fan_id, &status); + if (ret != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_HARDWARE, "Failed to get fan status after setting"); + return ret; + } + cJSON *data = status_to_json(fan_id, &status); ts_api_result_ok(result, data); return ESP_OK; diff --git a/components/ts_api/src/ts_api_https.c.bak b/components/ts_api/src/ts_api_https.c.bak index 2e3efde4..7101a758 100644 --- a/components/ts_api/src/ts_api_https.c.bak +++ b/components/ts_api/src/ts_api_https.c.bak @@ -147,7 +147,7 @@ static esp_err_t api_https_status(const cJSON *params, ts_api_result_t *result) * 参数: * { * "common_name": "TianShanOS", // 可选,默认 "TianShanOS" - * "organization": "HiddenPeak Labs", // 可选,默认 "HiddenPeak Labs" + * "organization": "TianShanOS", // 可选,默认 "TianShanOS" * "country": "CN", // 可选,默认 "CN" * "days": 365 // 可选,默认 365 天 * } @@ -168,7 +168,7 @@ static esp_err_t api_cert_generate(const cJSON *params, ts_api_result_t *result) const cJSON *days_obj = cJSON_GetObjectItem(params, "days"); const char *cn = (cn_obj && cJSON_IsString(cn_obj)) ? cn_obj->valuestring : "TianShanOS"; - const char *org = (org_obj && cJSON_IsString(org_obj)) ? org_obj->valuestring : "HiddenPeak Labs"; + const char *org = (org_obj && cJSON_IsString(org_obj)) ? org_obj->valuestring : "TianShanOS"; const char *country = (country_obj && cJSON_IsString(country_obj)) ? country_obj->valuestring : "CN"; uint32_t days = (days_obj && cJSON_IsNumber(days_obj)) ? days_obj->valueint : 365; @@ -264,7 +264,7 @@ static esp_err_t api_cert_upload(const cJSON *params, ts_api_result_t *result) * "has_certificate": true, * "cert_info": { * "common_name": "TianShanOS", - * "organization": "HiddenPeak Labs", + * "organization": "TianShanOS", * "country": "CN", * "valid_from": 1737705600, * "valid_to": 1769241600, diff --git a/components/ts_api/src/ts_api_key.c b/components/ts_api/src/ts_api_key.c index c80b5919..a18932da 100644 --- a/components/ts_api/src/ts_api_key.c +++ b/components/ts_api/src/ts_api_key.c @@ -10,6 +10,7 @@ #include "ts_keystore.h" #include "ts_log.h" #include "esp_heap_caps.h" +#include "nvs.h" #include #define TAG "api_key" @@ -239,21 +240,53 @@ static esp_err_t api_key_generate(const cJSON *params, ts_api_result_t *result) .hidden = hidden, }; - esp_err_t ret = ts_keystore_generate_key_ex(id->valuestring, type, &opts); + char request_id[32] = "legacy"; + const cJSON *rid = cJSON_GetObjectItem(params, "request_id"); + if (cJSON_IsString(rid) && strlen(rid->valuestring) < sizeof(request_id)) { + bool valid = rid->valuestring[0] != '\0'; + for (const char *p = rid->valuestring; *p; ++p) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) valid = false; + } + if (valid) strcpy(request_id, rid->valuestring); + } + ts_keystore_generate_result_t detail; + esp_err_t ret = ts_keystore_generate_key_with_result(id->valuestring, type, &opts, &detail); + TS_LOGI(TAG, "key.generate request=%s storage id=%s ret=%s stage=%s stored=%d cleanup=%s", request_id, id->valuestring, + esp_err_to_name(ret), detail.failed_stage ? detail.failed_stage : "saved", detail.stored, + esp_err_to_name(detail.cleanup_error)); if (ret != ESP_OK) { - if (ret == ESP_ERR_NO_MEM) { - ts_api_result_error(result, TS_API_ERR_NO_MEM, "Storage full"); + const char *reason = ret == ESP_ERR_NO_MEM ? "Memory allocation failed" : + ret == ESP_ERR_NVS_NOT_ENOUGH_SPACE ? "key_storage_full" : + strcmp(detail.failed_stage, "id_occupied") == 0 ? "key_id_occupied" : + strcmp(detail.failed_stage, "index_capacity") == 0 ? "key_limit_reached" : + ret == ESP_ERR_INVALID_ARG ? "key_invalid_id" : "key_storage_failed"; + ts_api_result_error(result, ret == ESP_ERR_NO_MEM ? TS_API_ERR_NO_MEM : + ret == ESP_ERR_INVALID_ARG ? TS_API_ERR_INVALID_ARG : TS_API_ERR_INTERNAL, reason); + cJSON *data = cJSON_CreateObject(); + if (data && cJSON_AddStringToObject(data, "id", id->valuestring) && + cJSON_AddStringToObject(data, "failed_stage", detail.failed_stage) && + cJSON_AddStringToObject(data, "esp_error", esp_err_to_name(ret)) && + cJSON_AddBoolToObject(data, "cleanup_complete", detail.cleanup_complete) && + cJSON_AddStringToObject(data, "cleanup_error", esp_err_to_name(detail.cleanup_error))) { + result->data = data; } else { - ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to generate key"); + cJSON_Delete(data); + TS_LOGE(TAG, "key.generate request=%s error response allocation failed", request_id); } return ret; } - + cJSON *data = cJSON_CreateObject(); - cJSON_AddBoolToObject(data, "generated", true); - cJSON_AddStringToObject(data, "id", id->valuestring); - cJSON_AddStringToObject(data, "type", ts_keystore_type_to_string(type)); - + if (!data || !cJSON_AddBoolToObject(data, "generated", true) || + !cJSON_AddStringToObject(data, "id", id->valuestring) || + !cJSON_AddStringToObject(data, "type", ts_keystore_type_to_string(type))) { + cJSON_Delete(data); + TS_LOGE(TAG, "key.generate request=%s saved id=%s but response allocation failed", request_id, id->valuestring); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "key_response_failed"); + return ESP_ERR_NO_MEM; + } + ts_api_result_ok(result, data); return ESP_OK; } diff --git a/components/ts_api/src/ts_api_led.c b/components/ts_api/src/ts_api_led.c index 37e6b48c..2f9ea3ab 100644 --- a/components/ts_api/src/ts_api_led.c +++ b/components/ts_api/src/ts_api_led.c @@ -679,6 +679,7 @@ static esp_err_t api_led_filter_start(const cJSON *params, ts_api_result_t *resu cJSON *width_param = cJSON_GetObjectItem(params, "width"); cJSON *wavelength_param = cJSON_GetObjectItem(params, "wavelength"); cJSON *amplitude_param = cJSON_GetObjectItem(params, "amplitude"); + cJSON *frequency_param = cJSON_GetObjectItem(params, "frequency"); // 提取 angle 和 width 参数 float angle = angle_param && cJSON_IsNumber(angle_param) ? (float)cJSON_GetNumberValue(angle_param) : 0.0f; @@ -756,30 +757,36 @@ static esp_err_t api_led_filter_start(const cJSON *params, ts_api_result_t *resu case TS_LED_EFFECT_COLOR_SHIFT: config.params.color_shift.speed = speed * 3.6f; break; + case TS_LED_EFFECT_FADE_IN: + case TS_LED_EFFECT_FADE_OUT: + config.params.fade.duration_ms = (uint16_t)(1000.0f / freq); + config.params.fade.auto_remove = false; + break; case TS_LED_EFFECT_SCANLINE: config.params.scanline.speed = speed; config.params.scanline.width = width > 0 ? width : 3; config.params.scanline.angle = angle; // 0-360度 - config.params.scanline.intensity = intensity > 0 ? intensity : 150; + config.params.scanline.intensity = intensity >= 0 ? intensity : 150; break; case TS_LED_EFFECT_WAVE: config.params.wave.speed = speed; config.params.wave.wavelength = wavelength > 0 ? wavelength : 8.0f; - config.params.wave.amplitude = amplitude > 0 ? amplitude : 128; + config.params.wave.amplitude = amplitude >= 0 ? amplitude : 128; config.params.wave.angle = angle; // 0-360° break; case TS_LED_EFFECT_GLITCH: - config.params.glitch.intensity = intensity > 0 ? intensity : speed; - config.params.glitch.frequency = 10; + config.params.glitch.intensity = intensity >= 0 ? intensity : speed; + config.params.glitch.frequency = cJSON_IsNumber(frequency_param) + ? frequency_param->valueint : 10; break; case TS_LED_EFFECT_RAINBOW: config.params.rainbow.speed = speed; - config.params.rainbow.saturation = saturation > 0 ? saturation : 255; + config.params.rainbow.saturation = saturation >= 0 ? saturation : 255; break; case TS_LED_EFFECT_SPARKLE: config.params.sparkle.speed = speed > 0 ? speed : 10.0f; // 降低默认速度 - config.params.sparkle.density = density > 0 ? density : 50; - config.params.sparkle.decay = decay > 0 ? decay : 150; // 提高decay让余晖更明显 + config.params.sparkle.density = density >= 0 ? density : 50; + config.params.sparkle.decay = decay >= 0 ? decay : 150; // 提高decay让余晖更明显 break; case TS_LED_EFFECT_PLASMA: config.params.plasma.speed = speed / 10.0f; @@ -792,7 +799,8 @@ static esp_err_t api_led_filter_start(const cJSON *params, ts_api_result_t *resu config.params.posterize.levels = levels > 0 ? levels : (2 + speed * 14 / 100); break; case TS_LED_EFFECT_CONTRAST: - config.params.contrast.amount = amount >= -100 && amount <= 100 ? amount : (speed - 50) * 2; + config.params.contrast.amount = cJSON_IsNumber(amount_param) && amount >= -100 && amount <= 100 + ? amount : (speed - 50) * 2; break; default: break; diff --git a/components/ts_api/src/ts_api_lpmu_access.c b/components/ts_api/src/ts_api_lpmu_access.c index 492de93a..c1729fb6 100644 --- a/components/ts_api/src/ts_api_lpmu_access.c +++ b/components/ts_api/src/ts_api_lpmu_access.c @@ -10,17 +10,22 @@ #include "ts_scp.h" #include "ts_ssh_client.h" #include "ts_ssh_hosts_config.h" +#include "ts_storage.h" #include "ts_usb_mux.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include #include #include #include #include #include #include +#include +#include #define TAG "api_lpmu" @@ -29,6 +34,8 @@ #define LPMU_REMOTE_TARBALL "lpmu-agx-network-setup.tar.gz" #define LPMU_STACK_SIZE 8192 #define LPMU_OUTPUT_TAIL_MAX 1024 +#define LPMU_LOG_PATH "/sdcard/logs/lpmu-access.log" +#define LPMU_LOG_CHUNK 1024 extern const uint8_t lpmu_archive_start[] asm("_binary_lpmu_agx_network_setup_tar_gz_start"); extern const uint8_t lpmu_archive_end[] asm("_binary_lpmu_agx_network_setup_tar_gz_end"); @@ -53,19 +60,36 @@ typedef struct { uint32_t run_id; bool running; lpmu_stage_t stage; + lpmu_stage_t result_stage; /* Published only after run resources are closed. */ esp_err_t esp_error; int exit_code; uint32_t bytes_transferred; uint32_t bytes_total; uint32_t output_bytes; size_t output_len; + size_t log_len; + esp_err_t log_error; + bool internet_confirmed; + bool configuration_complete; + char script_stdout_tail[64]; + size_t script_stdout_len; char last_error[160]; char output_tail[LPMU_OUTPUT_TAIL_MAX]; + /* Fixed-size measurements; exported only when diagnostics=true. */ + uint64_t log_init_us; + uint64_t log_write_us; + uint64_t log_write_max_us; + uint64_t log_read_us; + uint64_t log_read_max_us; + uint32_t log_write_count; + uint32_t log_read_count; + uint32_t stack_min_free_bytes; } lpmu_status_t; static SemaphoreHandle_t s_lpmu_mutex = NULL; static TaskHandle_t s_lpmu_task = NULL; static uint32_t s_next_run_id = 0; +static int s_run_log = -1; static lpmu_status_t s_status = { .stage = LPMU_STAGE_IDLE, .exit_code = -1, @@ -137,7 +161,7 @@ static void lpmu_set_stage(lpmu_stage_t stage) static void lpmu_set_error_locked(esp_err_t err, int exit_code, const char *fmt, va_list args) { - s_status.stage = LPMU_STAGE_FAILED; + s_status.result_stage = LPMU_STAGE_FAILED; s_status.esp_error = err; s_status.exit_code = exit_code; vsnprintf(s_status.last_error, sizeof(s_status.last_error), fmt, args); @@ -156,13 +180,32 @@ static void lpmu_fail(esp_err_t err, int exit_code, const char *fmt, ...) static void lpmu_success(int exit_code) { lpmu_lock(); - s_status.stage = LPMU_STAGE_SUCCESS; + s_status.result_stage = LPMU_STAGE_SUCCESS; s_status.esp_error = ESP_OK; s_status.exit_code = exit_code; s_status.last_error[0] = '\0'; lpmu_unlock(); } +static void lpmu_finish(void) +{ + // running stays true while close can block. Readers and new starts remain + // excluded; status queries need not wait for SD I/O. + int close_result = 0; + if (s_run_log >= 0) { + close_result = close(s_run_log); + s_run_log = -1; + } + uint32_t stack_free = s_lpmu_task ? uxTaskGetStackHighWaterMark(NULL) : UINT32_MAX; + lpmu_lock(); + if (close_result != 0) s_status.log_error = ESP_FAIL; + s_status.stage = s_status.result_stage; + s_status.stack_min_free_bytes = stack_free; + s_status.running = false; + s_lpmu_task = NULL; + lpmu_unlock(); +} + static void lpmu_append_tail_locked(const char *data, size_t len) { const size_t cap = sizeof(s_status.output_tail) - 1; @@ -195,19 +238,67 @@ static void lpmu_append_tail_locked(const char *data, size_t len) s_status.output_tail[s_status.output_len] = '\0'; } +static void lpmu_append_log(const char *data, size_t len) +{ + // Only the running task owns this descriptor; another start is excluded. + lpmu_lock(); + bool available = s_status.log_error == ESP_OK; + lpmu_unlock(); + if (s_run_log < 0 || !available) return; + int64_t started = esp_timer_get_time(); + ssize_t written = write(s_run_log, data, len); + uint64_t elapsed = esp_timer_get_time() - started; + lpmu_lock(); + s_status.log_write_count++; + s_status.log_write_us += elapsed; + if (elapsed > s_status.log_write_max_us) s_status.log_write_max_us = elapsed; + if (written > 0) s_status.log_len += (size_t)written; + if (written < 0 || (size_t)written != len) s_status.log_error = ESP_FAIL; + lpmu_unlock(); +} + +static void lpmu_record_command(const char *command) +{ + lpmu_append_log("$ ", 2); + lpmu_append_log(command, strlen(command)); + lpmu_append_log("\n", 1); +} + static void lpmu_output_cb(const char *data, size_t len, bool is_stderr, void *user_data) { - (void)is_stderr; (void)user_data; lpmu_lock(); lpmu_append_tail_locked(data, len); + if (s_status.stage == LPMU_STAGE_RUNNING_SCRIPT && !is_stderr) { + // Retain stdout overlap so SSH chunk boundaries and interleaved stderr + // cannot hide or manufacture the script's two confirmation messages. + char scan[sizeof(s_status.script_stdout_tail) + 512 + 1]; + const char *cursor = data; + size_t remaining = len; + while (remaining) { + size_t chunk = remaining < 512 ? remaining : 512; + size_t total = s_status.script_stdout_len + chunk; + memcpy(scan, s_status.script_stdout_tail, s_status.script_stdout_len); + memcpy(scan + s_status.script_stdout_len, cursor, chunk); + scan[total] = '\0'; + if (strstr(scan, "✓ 互联网连接正常!")) s_status.internet_confirmed = true; + if (strstr(scan, "=== 配置完成 ===")) s_status.configuration_complete = true; + size_t keep = total < sizeof(s_status.script_stdout_tail) ? total : sizeof(s_status.script_stdout_tail) - 1; + memcpy(s_status.script_stdout_tail, scan + total - keep, keep); + s_status.script_stdout_len = keep; + cursor += chunk; + remaining -= chunk; + } + } lpmu_unlock(); + lpmu_append_log(data, len); } static esp_err_t lpmu_run_command(ts_ssh_session_t session, const char *command, int *exit_code) { int code = -1; + lpmu_record_command(command); esp_err_t ret = ts_ssh_exec_stream(session, command, lpmu_output_cb, NULL, &code); if (exit_code) { *exit_code = code; @@ -242,7 +333,7 @@ static bool lpmu_verify_known_host(ts_ssh_session_t session, char *err, size_t e static void lpmu_task(void *arg) { - (void)arg; + char *sudo_input = (char *)arg; ts_ssh_session_t session = NULL; char *private_key = NULL; @@ -252,6 +343,18 @@ static void lpmu_task(void *arg) esp_err_t ret; char verify_error[128] = {0}; + int64_t log_started = esp_timer_get_time(); + esp_err_t log_error = ts_storage_mkdir_p("/sdcard/logs"); + if (log_error == ESP_OK) { + s_run_log = open(LPMU_LOG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0666); + if (s_run_log < 0) log_error = ESP_FAIL; + } + uint64_t log_elapsed = esp_timer_get_time() - log_started; + lpmu_lock(); + s_status.log_error = log_error; + s_status.log_init_us = log_elapsed; + lpmu_unlock(); + lpmu_set_stage(LPMU_STAGE_FINDING_HOST); ret = ts_ssh_hosts_config_find(LPMU_HOST, LPMU_PORT, NULL, &host_cfg); if (ret != ESP_OK) { @@ -355,10 +458,12 @@ static void lpmu_task(void *arg) } lpmu_set_stage(LPMU_STAGE_RUNNING_SCRIPT); - ret = lpmu_run_command(session, - "cd \"$HOME/lpmu-agx-network-setup/lpmu\" && " - "sudo -n ./setup-smart-route.sh", - &exit_code); + const char *command = "cd \"$HOME/lpmu-agx-network-setup/lpmu\" && " + "sudo -S -k -p '' -- ./setup-smart-route.sh"; + lpmu_record_command(command); + ret = ts_ssh_exec_stream_input(session, command, + sudo_input, strlen(sudo_input), + lpmu_output_cb, NULL, &exit_code); if (ret != ESP_OK || exit_code != 0) { lpmu_fail(ret != ESP_OK ? ret : ESP_FAIL, exit_code, "setup-smart-route failed (exit=%d): %s", exit_code, @@ -366,9 +471,19 @@ static void lpmu_task(void *arg) goto cleanup; } + lpmu_lock(); + bool confirmed = s_status.internet_confirmed && s_status.configuration_complete; + lpmu_unlock(); + if (!confirmed) { + lpmu_fail(ESP_ERR_INVALID_STATE, exit_code, + "script exited with 0 but did not report Internet connectivity and configuration completion"); + goto cleanup; + } lpmu_success(exit_code); cleanup: + lpmu_secure_zero(sudo_input, strlen(sudo_input)); + free(sudo_input); if (session) { ts_ssh_disconnect(session); ts_ssh_session_destroy(session); @@ -378,17 +493,19 @@ static void lpmu_task(void *arg) free(private_key); } - lpmu_lock(); - s_status.running = false; - s_lpmu_task = NULL; - lpmu_unlock(); + lpmu_finish(); vTaskDelete(NULL); } static esp_err_t api_lpmu_access_start(const cJSON *params, ts_api_result_t *result) { - (void)params; + const cJSON *password = cJSON_GetObjectItemCaseSensitive(params, "sudo_password"); + if (!cJSON_IsString(password) || !password->valuestring[0] || + strpbrk(password->valuestring, "\r\n")) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "A single-line sudo_password is required"); + return ESP_ERR_INVALID_ARG; + } esp_err_t ret = lpmu_ensure_mutex(); if (ret != ESP_OK) { @@ -416,7 +533,20 @@ static esp_err_t api_lpmu_access_start(const cJSON *params, ts_api_result_t *res return ESP_ERR_INVALID_STATE; } + size_t password_len = strlen(password->valuestring); + char *sudo_input = malloc(password_len + 2); + if (!sudo_input) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "Failed to allocate sudo input"); + return ESP_ERR_NO_MEM; + } + memcpy(sudo_input, password->valuestring, password_len); + sudo_input[password_len] = '\n'; + sudo_input[password_len + 1] = '\0'; + memset(&s_status, 0, sizeof(s_status)); + s_status.log_error = ESP_ERR_INVALID_STATE; /* No log until the task opens it. */ + s_status.stack_min_free_bytes = UINT32_MAX; s_status.run_id = ++s_next_run_id; if (s_next_run_id == 0) { s_next_run_id = 1; @@ -430,13 +560,12 @@ static esp_err_t api_lpmu_access_start(const cJSON *params, ts_api_result_t *res lpmu_unlock(); BaseType_t task_ret = xTaskCreate(lpmu_task, "lpmu_access", LPMU_STACK_SIZE, - NULL, 5, &s_lpmu_task); + sudo_input, 5, &s_lpmu_task); if (task_ret != pdPASS) { + lpmu_secure_zero(sudo_input, password_len + 1); + free(sudo_input); lpmu_fail(ESP_ERR_NO_MEM, -1, "failed to create LPMU access task"); - lpmu_lock(); - s_status.running = false; - s_lpmu_task = NULL; - lpmu_unlock(); + lpmu_finish(); ts_api_result_error(result, TS_API_ERR_NO_MEM, "Failed to create LPMU access task"); return ESP_ERR_NO_MEM; } @@ -451,8 +580,6 @@ static esp_err_t api_lpmu_access_start(const cJSON *params, ts_api_result_t *res static esp_err_t api_lpmu_access_status(const cJSON *params, ts_api_result_t *result) { - (void)params; - esp_err_t ret = lpmu_ensure_mutex(); if (ret != ESP_OK) { ts_api_result_error(result, TS_API_ERR_NO_MEM, "Failed to initialize LPMU access state"); @@ -473,11 +600,128 @@ static esp_err_t api_lpmu_access_status(const cJSON *params, ts_api_result_t *re cJSON_AddNumberToObject(data, "bytes_total", snap.bytes_total); cJSON_AddNumberToObject(data, "output_bytes", snap.output_bytes); cJSON_AddBoolToObject(data, "output_truncated", snap.output_bytes >= LPMU_OUTPUT_TAIL_MAX); + cJSON_AddBoolToObject(data, "internet_confirmed", snap.internet_confirmed); + cJSON_AddBoolToObject(data, "configuration_complete", snap.configuration_complete); cJSON_AddStringToObject(data, "last_error", snap.last_error); cJSON_AddStringToObject(data, "output_tail", snap.output_tail); cJSON_AddStringToObject(data, "host", LPMU_HOST); cJSON_AddNumberToObject(data, "port", LPMU_PORT); + const cJSON *diagnostics = cJSON_GetObjectItemCaseSensitive(params, "diagnostics"); + if (cJSON_IsTrue(diagnostics) || (cJSON_IsNumber(diagnostics) && diagnostics->valuedouble == 1)) { + cJSON *diag = cJSON_AddObjectToObject(data, "diagnostics"); + cJSON_AddNumberToObject(diag, "log_init_us", snap.log_init_us); + cJSON_AddNumberToObject(diag, "log_write_us", snap.log_write_us); + cJSON_AddNumberToObject(diag, "log_write_max_us", snap.log_write_max_us); + cJSON_AddNumberToObject(diag, "log_read_us", snap.log_read_us); + cJSON_AddNumberToObject(diag, "log_read_max_us", snap.log_read_max_us); + cJSON_AddNumberToObject(diag, "log_write_count", snap.log_write_count); + cJSON_AddNumberToObject(diag, "log_read_count", snap.log_read_count); + cJSON_AddNumberToObject(diag, "stack_allocated_bytes", LPMU_STACK_SIZE); + if (snap.run_id && snap.stack_min_free_bytes != UINT32_MAX) + cJSON_AddNumberToObject(diag, "stack_min_free_bytes", snap.stack_min_free_bytes); + else cJSON_AddNullToObject(diag, "stack_min_free_bytes"); + } + + ts_api_result_ok(result, data); + return ESP_OK; +} + +static esp_err_t api_lpmu_access_log(const cJSON *params, ts_api_result_t *result) +{ + const cJSON *run = cJSON_GetObjectItemCaseSensitive(params, "run_id"); + const cJSON *offset = cJSON_GetObjectItemCaseSensitive(params, "offset"); + double position = offset ? offset->valuedouble : 0; + if (!cJSON_IsNumber(run) || (offset && !cJSON_IsNumber(offset)) || !(position >= 0)) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "run_id and a nonnegative offset are required"); + return ESP_ERR_INVALID_ARG; + } + lpmu_lock(); + if (run->valuedouble != s_status.run_id || s_status.run_id == 0) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Execution log for this run is no longer available"); + return ESP_ERR_NOT_FOUND; + } + if (s_status.running) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_BUSY, "Execution log is still being written"); + return ESP_ERR_INVALID_STATE; + } + if (s_status.log_error != ESP_OK) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Execution log could not be saved to SD card"); + return ESP_FAIL; + } + if (!(position <= s_status.log_len) || position != (size_t)position) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid execution log offset"); + return ESP_ERR_INVALID_ARG; + } + uint32_t run_id = s_status.run_id; + size_t log_len = s_status.log_len; + lpmu_unlock(); + + char *chunk = TS_MALLOC_PSRAM_ONLY(LPMU_LOG_CHUNK + 1); + if (!chunk) { + ts_api_result_error(result, TS_API_ERR_NO_MEM, "Failed to allocate execution log read buffer"); + return ESP_ERR_NO_MEM; + } + int64_t read_started = esp_timer_get_time(); + int file = open(LPMU_LOG_PATH, O_RDONLY); + size_t expected = log_len - (size_t)position; + if (expected > LPMU_LOG_CHUNK) expected = LPMU_LOG_CHUNK; + ssize_t received = -1; + bool read_failed = file < 0; + if (file >= 0) { + struct stat info; + off_t seek = (off_t)(size_t)position; + read_failed = fstat(file, &info) != 0 || info.st_size < 0 || + (uint64_t)info.st_size != log_len || seek < 0 || + lseek(file, seek, SEEK_SET) != seek; + if (!read_failed) received = read(file, chunk, expected); + if (close(file) != 0) read_failed = true; + } + if (received < 0 || (size_t)received != expected) read_failed = true; + size_t count = received > 0 ? (size_t)received : 0; + // Keep a UTF-8 character intact across JSON pages. + if (!read_failed && count) { + size_t start = count; + while (start && ((unsigned char)chunk[start - 1] & 0xc0) == 0x80) start--; + if (start) { + unsigned char lead = (unsigned char)chunk[start - 1]; + size_t width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1; + if (count - (start - 1) < width) { + if (position + count == log_len) read_failed = true; + else count = start - 1; + } + } else read_failed = true; + } + if (!count && position < log_len) read_failed = true; + + uint64_t read_elapsed = esp_timer_get_time() - read_started; + // The next task may have replaced the file while the mutex was released. + lpmu_lock(); + bool stale = s_status.run_id != run_id; + if (!stale) { + s_status.log_read_count++; + s_status.log_read_us += read_elapsed; + if (read_elapsed > s_status.log_read_max_us) s_status.log_read_max_us = read_elapsed; + } + lpmu_unlock(); + if (stale || read_failed) { + free(chunk); + ts_api_result_error(result, stale ? TS_API_ERR_NOT_FOUND : TS_API_ERR_INTERNAL, + stale ? "Execution log for this run is no longer available" : + "Failed to read execution log from SD card"); + return stale ? ESP_ERR_NOT_FOUND : ESP_FAIL; + } + chunk[count] = '\0'; + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "run_id", run_id); + cJSON_AddStringToObject(data, "output", chunk); + cJSON_AddNumberToObject(data, "next_offset", position + count); + cJSON_AddBoolToObject(data, "done", position + count >= log_len); + free(chunk); ts_api_result_ok(result, data); return ESP_OK; } @@ -499,6 +743,14 @@ static const ts_api_endpoint_t lpmu_access_endpoints[] = { .requires_auth = true, .permission = "network.view", }, + { + .name = "network.lpmu_access.log", + .description = "Get the execution log for an LPMU access run", + .category = TS_API_CAT_NETWORK, + .handler = api_lpmu_access_log, + .requires_auth = true, + .permission = "network.view", + }, }; esp_err_t ts_api_lpmu_access_register(void) diff --git a/components/ts_api/src/ts_api_service.h b/components/ts_api/src/ts_api_service.h new file mode 100644 index 00000000..d7c7d754 --- /dev/null +++ b/components/ts_api/src/ts_api_service.h @@ -0,0 +1,6 @@ +#pragma once +#include "ts_api.h" +#include "ts_ssh_service.h" +/* Shared admission response for service start, verification and stop. */ +esp_err_t ts_api_service_control(const cJSON *params, ts_api_result_t *result, + ts_service_operation_t kind); diff --git a/components/ts_api/src/ts_api_ssh.c b/components/ts_api/src/ts_api_ssh.c index e72a9c09..656568bb 100644 --- a/components/ts_api/src/ts_api_ssh.c +++ b/components/ts_api/src/ts_api_ssh.c @@ -21,8 +21,11 @@ */ #include "ts_api.h" +#include "ts_action_manager.h" +#include "ts_api_service.h" #include "ts_core.h" /* TS_MALLOC_PSRAM, TS_STRDUP_PSRAM */ #include "ts_ssh_client.h" +#include "ts_ssh_probe.h" #include "ts_keystore.h" #include "ts_known_hosts.h" #include "ts_ssh_hosts_config.h" @@ -128,18 +131,20 @@ static esp_err_t configure_ssh_from_params(const cJSON *params, ts_ssh_config_t * @param params API 参数(包含 trust_new, accept_changed) * @param result API 结果(用于返回错误信息) * @param host_info_out 输出主机信息(可选) + * @param auto_trust_new 公钥部署沿用 0.5.1 的首次信任行为;显式 trust_new=false 仍生效 * @return ESP_OK 验证通过,其他表示失败 */ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, const cJSON *params, ts_api_result_t *result, - ts_known_host_t *host_info_out) + ts_known_host_t *host_info_out, + bool auto_trust_new) { /* 获取验证参数 */ const cJSON *trust_new_j = cJSON_GetObjectItem(params, "trust_new"); const cJSON *accept_changed_j = cJSON_GetObjectItem(params, "accept_changed"); - bool trust_new = cJSON_IsBool(trust_new_j) ? cJSON_IsTrue(trust_new_j) : false; + bool trust_new = cJSON_IsBool(trust_new_j) ? cJSON_IsTrue(trust_new_j) : auto_trust_new; bool accept_changed = cJSON_IsBool(accept_changed_j) ? cJSON_IsTrue(accept_changed_j) : false; /* 验证主机指纹 */ @@ -155,7 +160,7 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, const cJSON *confirmed = cJSON_GetObjectItem(params, "confirmed_fingerprint"); bool confirmed_match = cJSON_IsString(confirmed) && !strcmp(confirmed->valuestring, host_info.fingerprint); - trust_new = trust_new && confirmed_match; + trust_new = trust_new && (auto_trust_new || confirmed_match); accept_changed = accept_changed && confirmed_match; /* 输出主机信息 */ @@ -241,14 +246,15 @@ static esp_err_t verify_host_fingerprint(ts_ssh_session_t session, typedef struct { const cJSON *params; ts_api_result_t *result; + bool auto_trust_new; } verify_context_t; static esp_err_t verify_before_auth(ts_ssh_session_t session, void *opaque) { verify_context_t *ctx = opaque; - return verify_host_fingerprint(session, ctx->params, ctx->result, NULL); + return verify_host_fingerprint(session, ctx->params, ctx->result, NULL, ctx->auto_trust_new); } static esp_err_t connect_verified(ts_ssh_session_t session, const cJSON *params, - ts_api_result_t *result) { - verify_context_t ctx = {params, result}; + ts_api_result_t *result, bool auto_trust_new) { + verify_context_t ctx = {params, result, auto_trust_new}; return ts_ssh_connect_with_verifier(session, verify_before_auth, &ctx); } @@ -324,7 +330,7 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) } /* 连接(TCP 层) */ - ret = connect_verified(session, params, result); + ret = connect_verified(session, params, result, false); if (ret != ESP_OK) { const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) @@ -336,7 +342,7 @@ static esp_err_t api_ssh_exec(const cJSON *params, ts_api_result_t *result) /* 验证主机指纹 */ ts_known_host_t host_info = {0}; - ret = verify_host_fingerprint(session, params, result, &host_info); + ret = verify_host_fingerprint(session, params, result, &host_info, false); if (ret != ESP_OK) { /* result 已在 verify_host_fingerprint 中设置 */ ts_ssh_disconnect(session); @@ -613,7 +619,7 @@ static esp_err_t api_ssh_test(const cJSON *params, ts_api_result_t *result) } /* 测试连接(TCP 层) */ - ret = connect_verified(session, params, result); + ret = connect_verified(session, params, result, false); if (ret != ESP_OK) { if (result->code != TS_API_OK) { @@ -633,7 +639,7 @@ static esp_err_t api_ssh_test(const cJSON *params, ts_api_result_t *result) /* 验证主机指纹 */ ts_known_host_t host_info = {0}; - ret = verify_host_fingerprint(session, params, result, &host_info); + ret = verify_host_fingerprint(session, params, result, &host_info, false); if (ret != ESP_OK) { /* 主机验证失败(新主机或密钥不匹配) * result 中已包含详细信息,直接返回成功(HTTP 200) @@ -756,7 +762,8 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) return ret; } - ret = connect_verified(session, params, result); + /* 首次部署自动保存新主机指纹;已知主机指纹变化仍在认证前拒绝。 */ + ret = connect_verified(session, params, result, true); if (ret != ESP_OK) { const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) @@ -768,7 +775,7 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) /* 验证主机指纹 */ ts_known_host_t host_info = {0}; - ret = verify_host_fingerprint(session, params, result, &host_info); + ret = verify_host_fingerprint(session, params, result, &host_info, true); if (ret != ESP_OK) { /* result 已在 verify_host_fingerprint 中设置 */ ts_ssh_disconnect(session); @@ -778,7 +785,8 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) } /* 构建部署命令(与 CLI 逻辑一致) */ - char *deploy_cmd = TS_MALLOC_PSRAM(pubkey_len + 512); + size_t deploy_capacity = strlen(pubkey_data) * 4 + 256; + char *deploy_cmd = TS_MALLOC_PSRAM(deploy_capacity); if (!deploy_cmd) { ts_api_result_error(result, TS_API_ERR_INTERNAL, "Out of memory"); ts_ssh_disconnect(session); @@ -787,12 +795,14 @@ static esp_err_t api_ssh_copyid(const cJSON *params, ts_api_result_t *result) return ESP_ERR_NO_MEM; } - snprintf(deploy_cmd, pubkey_len + 512, - "mkdir -p ~/.ssh && chmod 700 ~/.ssh && " - "echo '%s' >> ~/.ssh/authorized_keys && " - "chmod 600 ~/.ssh/authorized_keys && " - "echo 'Key deployed successfully'", - pubkey_data); + if (!ts_ssh_copyid_command(pubkey_data, deploy_cmd, deploy_capacity)) { + free(deploy_cmd); + ts_ssh_disconnect(session); + ts_ssh_session_destroy(session); + free(pubkey_data); + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Cannot construct public key deployment command"); + return ESP_ERR_INVALID_ARG; + } /* 执行部署命令 */ ts_ssh_exec_result_t exec_result = {0}; @@ -1005,7 +1015,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) return ret; } - ret = connect_verified(session, params, result); + ret = connect_verified(session, params, result, false); if (ret != ESP_OK) { const char *err = ts_ssh_get_error(session); if (result->code == TS_API_OK) @@ -1018,7 +1028,7 @@ static esp_err_t api_ssh_revoke(const cJSON *params, ts_api_result_t *result) /* 验证主机指纹 */ ts_known_host_t host_info = {0}; - ret = verify_host_fingerprint(session, params, result, &host_info); + ret = verify_host_fingerprint(session, params, result, &host_info, false); if (ret != ESP_OK) { /* result 已在 verify_host_fingerprint 中设置 */ ts_ssh_disconnect(session); @@ -2033,6 +2043,8 @@ static esp_err_t api_ssh_commands_remove(const cJSON *params, ts_api_result_t *r ts_api_result_ok(result, NULL); } else if (ret == ESP_ERR_NOT_FOUND) { ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Command not found"); + } else if (ret == ESP_ERR_INVALID_STATE) { + ts_api_result_error(result, TS_API_ERR_BUSY, "service_delete_protected"); } else { ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to remove command"); } @@ -2429,7 +2441,36 @@ static esp_err_t api_ssh_commands_import(const cJSON *params, ts_api_result_t *r /* Registration */ /*===========================================================================*/ +esp_err_t ts_api_service_control(const cJSON *params, ts_api_result_t *result, ts_service_operation_t kind) { + const cJSON *id = cJSON_GetObjectItemCaseSensitive(params, "command_id"); + if (!cJSON_IsString(id) || !id->valuestring[0]) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "command_id required"); + return ESP_ERR_INVALID_ARG; + } + cJSON *data = cJSON_CreateObject(); + if (!data) return ESP_ERR_NO_MEM; + cJSON *operation = cJSON_AddNumberToObject(data, "operation_id", 0); + if (!operation) { cJSON_Delete(data); return ESP_ERR_NO_MEM; } + uint32_t operation_id = 0; + esp_err_t ret = ts_action_service_control(id->valuestring, kind, &operation_id); + if (ret != ESP_OK) { + cJSON_Delete(data); + ts_api_result_error(result, ret == ESP_ERR_INVALID_STATE ? TS_API_ERR_BUSY : + ret == ESP_ERR_NO_MEM ? TS_API_ERR_NO_MEM : TS_API_ERR_INTERNAL, esp_err_to_name(ret)); + return ret; + } + cJSON_SetNumberValue(operation, operation_id); + ts_api_result_ok(result, data); + return ESP_OK; +} + +static esp_err_t api_ssh_service_start(const cJSON *params, ts_api_result_t *result) { + return ts_api_service_control(params, result, TS_SERVICE_START); +} + static const ts_api_endpoint_t ssh_endpoints[] = { + {.name = "ssh.services.start", .description = "Start a stored managed service", + .category = TS_API_CAT_SECURITY, .handler = api_ssh_service_start, .requires_auth = true}, { .name = "ssh.exec", .description = "Execute remote command via SSH", diff --git a/components/ts_automation/CMakeLists.txt b/components/ts_automation/CMakeLists.txt index efba37f7..efb6e72b 100644 --- a/components/ts_automation/CMakeLists.txt +++ b/components/ts_automation/CMakeLists.txt @@ -7,6 +7,8 @@ idf_component_register( "src/ts_rule_codec.c" "src/ts_rule_store.c" "src/ts_action_manager.c" + "src/ts_action_filter.c" + "src/ts_action_store.c" INCLUDE_DIRS "include" REQUIRES diff --git a/components/ts_automation/include/ts_action_filter.h b/components/ts_automation/include/ts_action_filter.h new file mode 100644 index 00000000..98bec62c --- /dev/null +++ b/components/ts_automation/include/ts_action_filter.h @@ -0,0 +1,9 @@ +#pragma once +#include "ts_automation_types.h" +#include "cJSON.h" + +esp_err_t ts_action_filter_decode(const cJSON *json, ts_auto_filter_params_t *out); +/* Add filter_params to a template/rule LED object; omit when absent in old data. */ +bool ts_action_filter_encode(const ts_auto_filter_params_t *params, cJSON *led); +/* Add explicit parameters, in led.filter.start API units, to the API request. */ +bool ts_action_filter_api_params(const ts_auto_filter_params_t *params, cJSON *request); diff --git a/components/ts_automation/include/ts_action_manager.h b/components/ts_automation/include/ts_action_manager.h index 27e3c0fe..49bedcdb 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -25,6 +25,7 @@ #include "esp_err.h" #include "ts_automation_types.h" +#include "ts_ssh_service.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include @@ -100,6 +101,8 @@ typedef void (*ts_action_callback_t)(const ts_auto_action_t *action, */ typedef struct { ts_auto_action_t action; /**< Action definition */ + uint32_t service_operation_id; /**< Direct service operation; zero for automation. */ + ts_service_operation_t service_kind; ts_action_callback_t callback; /**< Completion callback */ void *user_data; /**< User data for callback */ uint8_t priority; /**< Priority (0=highest) */ @@ -238,6 +241,14 @@ esp_err_t ts_action_queue(const ts_auto_action_t *action, void *user_data, uint8_t priority); +/** + * Submit an execution snapshot after its action delay was handled by the rule + * sequencer. The caller keeps its snapshot; a successful queue submission owns + * a separate binding reference. The source action is never modified. + * Sync snapshots return the execution result; async snapshots return admission. + */ +esp_err_t ts_action_submit_prepared(const ts_auto_action_t *action); + /** * @brief Execute multiple actions in sequence * @@ -331,6 +342,10 @@ esp_err_t ts_action_exec_device(const ts_auto_action_device_t *device, * @param result Output result * @return ESP_OK on success, ESP_ERR_NOT_FOUND if command not found */ +/* Start/verify/stop independently of engine state. One direct control in flight. + * Returns admission without remote I/O; queued controls expire after 30 seconds. */ +esp_err_t ts_action_service_control(const char *id, ts_service_operation_t kind, uint32_t *operation_id); + esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ssh_ref, ts_action_result_t *result); diff --git a/components/ts_automation/include/ts_automation_types.h b/components/ts_automation/include/ts_automation_types.h index 9de43aa0..b3503e2d 100644 --- a/components/ts_automation/include/ts_automation_types.h +++ b/components/ts_automation/include/ts_automation_types.h @@ -309,6 +309,12 @@ typedef enum { /** * @brief LED action parameters */ +#define TS_AUTO_FILTER_PARAM_COUNT 13 +typedef struct { + uint16_t present; /**< Explicit parameters; zero is a value. */ + int16_t values[TS_AUTO_FILTER_PARAM_COUNT]; /**< Values in WebUI units. */ +} ts_auto_filter_params_t; + typedef struct { char device[TS_AUTO_NAME_MAX_LEN]; /**< LED device (board/touch/matrix) */ ts_led_ctrl_type_t ctrl_type; /**< Control type */ @@ -326,6 +332,7 @@ typedef struct { char qr_text[256]; /**< QR code content (for QRCODE type) */ char qr_ecc; /**< QR ECC level: L/M/Q/H */ char filter[32]; /**< Filter name (for FILTER type) */ + ts_auto_filter_params_t filter_params; bool center; /**< Center image/text */ bool loop; /**< Loop text scroll */ char scroll[16]; /**< Scroll direction: none/left/right/up/down */ diff --git a/components/ts_automation/src/ts_action_filter.c b/components/ts_automation/src/ts_action_filter.c new file mode 100644 index 00000000..aa052ace --- /dev/null +++ b/components/ts_automation/src/ts_action_filter.c @@ -0,0 +1,64 @@ +#include "ts_action_filter.h" +#include +#include + +static const struct { const char *name; int min, max; } fields[TS_AUTO_FILTER_PARAM_COUNT] = { + {"speed", 1, 100}, {"intensity", 0, 255}, {"density", 0, 255}, + {"decay", 0, 255}, {"scale", 1, 100}, {"levels", 2, 16}, + {"amount", 0, 100}, {"saturation", 0, 100}, {"angle", 0, 360}, + {"width", 1, 16}, {"wavelength", 1, 32}, {"amplitude", 0, 255}, + {"frequency", 0, 100}, +}; + +esp_err_t ts_action_filter_decode(const cJSON *json, ts_auto_filter_params_t *out) +{ + ts_auto_filter_params_t next = {0}; + if (!out) return ESP_ERR_INVALID_ARG; + if (json) { + if (!cJSON_IsObject(json)) return ESP_ERR_INVALID_ARG; + const cJSON *item; + cJSON_ArrayForEach(item, json) { + size_t i; + for (i = 0; i < TS_AUTO_FILTER_PARAM_COUNT; i++) + if (item->string && !strcmp(item->string, fields[i].name)) break; + if (i == TS_AUTO_FILTER_PARAM_COUNT || (next.present & (1u << i)) || + !cJSON_IsNumber(item) || !isfinite(item->valuedouble) || + trunc(item->valuedouble) != item->valuedouble || + item->valuedouble < fields[i].min || item->valuedouble > fields[i].max) + return ESP_ERR_INVALID_ARG; + next.present |= 1u << i; + next.values[i] = (int16_t)item->valuedouble; + } + } + *out = next; + return ESP_OK; +} + +static bool write(const ts_auto_filter_params_t *params, cJSON *json, bool api_units) +{ + if (params->present >> TS_AUTO_FILTER_PARAM_COUNT) return false; + for (size_t i = 0; i < TS_AUTO_FILTER_PARAM_COUNT; i++) { + if (!(params->present & (1u << i))) continue; + int value = params->values[i]; + if (value < fields[i].min || value > fields[i].max) return false; + if (api_units) { + if (!strcmp(fields[i].name, "saturation") || !strcmp(fields[i].name, "frequency")) + value = (int)round(value * 2.55); + else if (!strcmp(fields[i].name, "amount")) value -= 50; + } + if (!cJSON_AddNumberToObject(json, fields[i].name, value)) return false; + } + return true; +} + +bool ts_action_filter_encode(const ts_auto_filter_params_t *params, cJSON *led) +{ + if (!params->present) return true; + cJSON *json = cJSON_AddObjectToObject(led, "filter_params"); + return json && write(params, json, false); +} + +bool ts_action_filter_api_params(const ts_auto_filter_params_t *params, cJSON *request) +{ + return write(params, request, true); +} diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index b6ed64c9..127efed5 100644 --- a/components/ts_automation/src/ts_action_manager.c +++ b/components/ts_automation/src/ts_action_manager.c @@ -16,6 +16,9 @@ */ #include "ts_action_manager.h" +#include "ts_action_filter.h" +#include "ts_action_store.h" +#include "ts_api.h" #include "ts_variable.h" #include "ts_event.h" #include "ts_ssh_client.h" @@ -50,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +77,7 @@ static const char *TAG = "ts_action_mgr"; /** Action executor task priority */ #define ACTION_TASK_PRIORITY 5 +#define SERVICE_QUEUE_TIMEOUT_MS 30000 /** NVS namespace for action templates */ #define NVS_NAMESPACE "action_tpl" @@ -106,6 +111,8 @@ typedef struct { TaskHandle_t executor_task; atomic_bool running; atomic_bool accepting; + bool direct_closed; + unsigned direct_pending; /* caller + executor; at most one direct launch */ unsigned pending; /* producers + queued + executing, protected by stats_mutex */ /* Statistics */ @@ -123,7 +130,7 @@ typedef struct { ts_ssh_command_config_t command; ts_action_ssh_host_t host; bool pinned; - uint32_t registration; + uint32_t registration, control_id; } action_binding_t; typedef struct { atomic_uint refs; @@ -155,6 +162,32 @@ void ts_action_snapshot_release(ts_auto_action_t *a) { if (a) a->runtime_binding = NULL; } +static esp_err_t snapshot_command(ts_auto_action_t *out, bool for_start) { + action_binding_t *binding = + heap_caps_calloc(1, sizeof(*binding), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!binding) + return ESP_ERR_NO_MEM; + atomic_init(&binding->refs, 1); + esp_err_t ret = ts_ssh_commands_config_get(out->ssh_ref.cmd_id, &binding->command); + if (ret == ESP_OK) + ret = ts_action_get_ssh_host(binding->command.host_id, &binding->host); + if (ret == ESP_OK && for_start && !binding->command.enabled) + ret = ESP_ERR_INVALID_STATE; + if (ret == ESP_OK && binding->command.nohup && binding->command.service_mode) { + if (for_start && !ts_ssh_service_start_admissible(binding->command.id)) + ret = ESP_ERR_INVALID_STATE; + else { + ret = ts_ssh_service_pin(&binding->command, binding->host.host, binding->host.port, &binding->registration); + binding->pinned = ret == ESP_OK; + } + } + if (ret != ESP_OK) { + free(binding); + return ret; + } + out->runtime_binding = binding; + return ESP_OK; +} esp_err_t ts_action_snapshot(const ts_auto_action_t *source, ts_auto_action_t *out) { *out = *source; out->runtime_binding = NULL; @@ -187,31 +220,9 @@ esp_err_t ts_action_snapshot(const ts_auto_action_t *source, ts_auto_action_t *o return ESP_OK; if (!source->template_id[0] && !source->runtime_snapshot) out->async = true; - action_binding_t *binding = - heap_caps_calloc(1, sizeof(*binding), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - if (!binding) - return ESP_ERR_NO_MEM; - atomic_init(&binding->refs, 1); - esp_err_t ret = ts_ssh_commands_config_get(out->ssh_ref.cmd_id, &binding->command); - if (ret == ESP_OK) - ret = ts_action_get_ssh_host(binding->command.host_id, &binding->host); - if (ret == ESP_OK && !binding->command.enabled) - ret = ESP_ERR_INVALID_STATE; - if (ret == ESP_OK && binding->command.nohup && binding->command.service_mode) { - if (!ts_ssh_service_start_admissible(binding->command.id)) - ret = ESP_ERR_INVALID_STATE; - else { - ret = ts_ssh_service_pin(&binding->command, binding->host.host, binding->host.port, &binding->registration); - binding->pinned = ret == ESP_OK; - } - } - if (ret != ESP_OK) { - free(binding); - return ret; - } - out->runtime_binding = binding; - return ESP_OK; + return snapshot_command(out, true); } + /*===========================================================================*/ /* Forward Declarations */ /*===========================================================================*/ @@ -325,9 +336,11 @@ void ts_action_deferred_load_task(void *arg) } ESP_LOGI(TAG, "Deferred action template loading started"); - ts_action_templates_load(); - ESP_LOGI(TAG, "Deferred action template loading complete: %d templates", - s_ctx->template_count); + esp_err_t ret = ts_action_templates_load(); + if (ret == ESP_OK) { + ESP_LOGI(TAG, "Deferred action template loading complete: %d templates", + s_ctx->template_count); + } else ESP_LOGE(TAG, "Deferred action template loading failed: %s", esp_err_to_name(ret)); vTaskDelete(NULL); } @@ -341,7 +354,11 @@ esp_err_t ts_action_manager_deinit(void) ESP_LOGI(TAG, "Deinitializing action manager"); /* Admission stays closed until every producer/executor has released ownership. */ - if (ts_action_manager_quiesce() != ESP_OK) return ESP_ERR_TIMEOUT; + xSemaphoreTake(s_ctx->stats_mutex, portMAX_DELAY); + s_ctx->direct_closed = true; + bool direct_idle = s_ctx->direct_pending == 0; + xSemaphoreGive(s_ctx->stats_mutex); + if (ts_action_manager_quiesce() != ESP_OK || !direct_idle) return ESP_ERR_TIMEOUT; s_ctx->running = false; xSemaphoreTake(s_ctx->stats_mutex, portMAX_DELAY); bool exiting = s_ctx->executor_task != NULL; @@ -536,6 +553,26 @@ static void action_finished(void) { --s_ctx->pending; xSemaphoreGive(s_ctx->stats_mutex); } +static bool direct_service_admit(void) { + if (!s_ctx) return false; + xSemaphoreTake(s_ctx->stats_mutex, portMAX_DELAY); + bool accept = s_ctx->running && !s_ctx->direct_closed && !s_ctx->direct_pending; + if (accept) s_ctx->direct_pending = 2; + xSemaphoreGive(s_ctx->stats_mutex); + return accept; +} +static void direct_service_finished(void) { + xSemaphoreTake(s_ctx->stats_mutex, portMAX_DELAY); + --s_ctx->direct_pending; + xSemaphoreGive(s_ctx->stats_mutex); +} +static void entry_finished(const ts_action_queue_entry_t *entry, esp_err_t error) { + if (entry->service_operation_id) { + bool executed = ts_ssh_service_complete_operation(entry->action.ssh_ref.cmd_id, entry->service_operation_id, error); + if (executed && entry->service_kind == TS_SERVICE_VERIFY) ts_ssh_service_recovery_kick(entry->action.ssh_ref.cmd_id); + direct_service_finished(); + } else action_finished(); +} bool ts_action_manager_accepting(void) { return s_ctx && s_ctx->accepting; } esp_err_t ts_action_manager_quiesce(void) { if (!s_ctx) return ESP_OK; @@ -553,7 +590,24 @@ esp_err_t ts_action_manager_resume(void) { xSemaphoreGive(s_ctx->stats_mutex); return idle ? ESP_OK : ESP_ERR_INVALID_STATE; } -esp_err_t ts_action_manager_execute(const ts_auto_action_t *action, ts_action_result_t *result) { +static esp_err_t prepare_action_entry(const ts_auto_action_t *action, + ts_action_queue_entry_t *entry, bool delay_handled) { + esp_err_t ret = ESP_OK; + if (action->runtime_snapshot) { + entry->action = *action; + ts_action_snapshot_retain(&entry->action); + } else { + ts_ssh_binding_lock(); + ret = ts_action_snapshot(action, &entry->action); + ts_ssh_binding_unlock(); + } + if (ret == ESP_OK && delay_handled) + entry->action.delay_ms = 0; + return ret; +} + +static esp_err_t action_manager_execute(const ts_auto_action_t *action, + ts_action_result_t *result, bool delay_handled) { if (!s_ctx || !action || !s_ctx->running || !s_ctx->executor_task) return ESP_ERR_INVALID_STATE; if (!action_admit()) return ESP_ERR_INVALID_STATE; @@ -566,26 +620,19 @@ esp_err_t ts_action_manager_execute(const ts_auto_action_t *action, ts_action_re return ESP_ERR_NO_MEM; } atomic_init(&c->refs, 2); - ts_auto_action_t frozen; - esp_err_t ret = ESP_OK; - if (action->runtime_snapshot) { - frozen = *action; - ts_action_snapshot_retain(&frozen); - } else { - ts_ssh_binding_lock(); - ret = ts_action_snapshot(action, &frozen); - ts_ssh_binding_unlock(); - } + ts_action_queue_entry_t entry = {0}; + esp_err_t ret = prepare_action_entry(action, &entry, delay_handled); if (ret != ESP_OK) { completion_release(c); completion_release(c); action_finished(); action_finished(); return ret; } - ts_action_queue_entry_t entry = { - .action = frozen, .completion = c, .done_sem = c->semaphore, .result_ptr = &c->result}; + entry.completion = c; + entry.done_sem = c->semaphore; + entry.result_ptr = &c->result; if (xQueueSend(s_ctx->action_queue, &entry, pdMS_TO_TICKS(100)) != pdTRUE) { - ts_action_snapshot_release(&frozen); + ts_action_snapshot_release(&entry.action); completion_release(c); completion_release(c); action_finished(); action_finished(); @@ -610,38 +657,30 @@ esp_err_t ts_action_manager_execute(const ts_auto_action_t *action, ts_action_re return ret; } -esp_err_t ts_action_queue(const ts_auto_action_t *action, - ts_action_callback_t callback, - void *user_data, - uint8_t priority) +esp_err_t ts_action_manager_execute(const ts_auto_action_t *action, ts_action_result_t *result) { + return action_manager_execute(action, result, false); +} + +static esp_err_t action_queue(const ts_auto_action_t *action, + ts_action_callback_t callback, + void *user_data, + uint8_t priority, bool delay_handled) { if (!s_ctx || !s_ctx->running || !action) { return ESP_ERR_INVALID_ARG; } if (!action_admit()) return ESP_ERR_INVALID_STATE; - ts_auto_action_t frozen; - if (action->runtime_snapshot) { - frozen = *action; - ts_action_snapshot_retain(&frozen); - } else { - ts_ssh_binding_lock(); - esp_err_t ret = ts_action_snapshot(action, &frozen); - ts_ssh_binding_unlock(); - if (ret != ESP_OK) { action_finished(); action_finished(); return ret; } - } - ts_action_queue_entry_t entry = { - .action = frozen, - .callback = callback, - .user_data = user_data, - .priority = priority, - .enqueue_time = esp_timer_get_time() / 1000, - .done_sem = NULL, /* Async mode: no sync semaphore */ - .result_ptr = NULL /* Async mode: no result pointer */ - }; + ts_action_queue_entry_t entry = {0}; + esp_err_t ret = prepare_action_entry(action, &entry, delay_handled); + if (ret != ESP_OK) { action_finished(); action_finished(); return ret; } + entry.callback = callback; + entry.user_data = user_data; + entry.priority = priority; + entry.enqueue_time = esp_timer_get_time() / 1000; if (xQueueSend(s_ctx->action_queue, &entry, pdMS_TO_TICKS(100)) != pdTRUE) { - ts_action_snapshot_release(&frozen); + ts_action_snapshot_release(&entry.action); ESP_LOGW(TAG, "Action queue full"); action_finished(); action_finished(); return ESP_ERR_NO_MEM; @@ -658,6 +697,18 @@ esp_err_t ts_action_queue(const ts_auto_action_t *action, return ESP_OK; } +esp_err_t ts_action_queue(const ts_auto_action_t *action, ts_action_callback_t callback, + void *user_data, uint8_t priority) { + return action_queue(action, callback, user_data, priority, false); +} + +esp_err_t ts_action_submit_prepared(const ts_auto_action_t *action) { + if (!action || !action->runtime_snapshot) + return ESP_ERR_INVALID_ARG; + return action->async ? action_queue(action, NULL, NULL, 5, true) + : action_manager_execute(action, NULL, true); +} + esp_err_t ts_action_execute_sequence(const ts_auto_action_t *actions, int count, bool stop_on_error) @@ -697,7 +748,7 @@ esp_err_t ts_action_cancel_all(void) if (entry.done_sem) xSemaphoreGive(entry.done_sem); completion_release(entry.completion); - action_finished(); + entry_finished(&entry, ESP_ERR_INVALID_STATE); } ESP_LOGI(TAG, "Cancelled all pending actions"); return ESP_OK; @@ -738,7 +789,12 @@ esp_err_t ts_action_exec_ssh(const ts_auto_action_ssh_t *ssh, snprintf(result->output, sizeof(result->output), "Out of memory"); return ESP_ERR_NO_MEM; } - ts_action_expand_variables(ssh->command, expanded_cmd, TS_SSH_CMD_COMMAND_MAX); + if (ts_action_expand_variables(ssh->command, expanded_cmd, TS_SSH_CMD_COMMAND_MAX) < 0) { + free(expanded_cmd); + result->status = TS_ACTION_STATUS_FAILED; + snprintf(result->output, sizeof(result->output), "Expanded command exceeds capacity"); + return ESP_ERR_INVALID_SIZE; + } ESP_LOGD(TAG, "SSH host=%s, command bytes=%u", ssh->host_ref, (unsigned)strlen(expanded_cmd)); @@ -890,30 +946,28 @@ esp_err_t ts_action_exec_led(const ts_auto_action_led_t *led, ts_auto_action_led_t led_expanded; memcpy(&led_expanded, led, sizeof(ts_auto_action_led_t)); - /* 展开字符串字段中的变量 */ - char expanded_buf[128]; - - if (led->text[0]) { - ts_action_expand_variables(led->text, expanded_buf, sizeof(expanded_buf)); - strncpy(led_expanded.text, expanded_buf, sizeof(led_expanded.text) - 1); + /* Expand into each actual destination; reject rather than truncate. */ + if (ts_action_expand_variables(led->text, led_expanded.text, sizeof(led_expanded.text)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; } - if (led->image_path[0]) { - ts_action_expand_variables(led->image_path, expanded_buf, sizeof(expanded_buf)); - strncpy(led_expanded.image_path, expanded_buf, sizeof(led_expanded.image_path) - 1); + if (ts_action_expand_variables(led->image_path, led_expanded.image_path, sizeof(led_expanded.image_path)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; } - if (led->qr_text[0]) { - ts_action_expand_variables(led->qr_text, expanded_buf, sizeof(expanded_buf)); - strncpy(led_expanded.qr_text, expanded_buf, sizeof(led_expanded.qr_text) - 1); + if (ts_action_expand_variables(led->qr_text, led_expanded.qr_text, sizeof(led_expanded.qr_text)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; } - if (led->filter[0]) { - ts_action_expand_variables(led->filter, expanded_buf, sizeof(expanded_buf)); - strncpy(led_expanded.filter, expanded_buf, sizeof(led_expanded.filter) - 1); + if (ts_action_expand_variables(led->filter, led_expanded.filter, sizeof(led_expanded.filter)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; } - if (led->effect[0]) { - ts_action_expand_variables(led->effect, expanded_buf, sizeof(expanded_buf)); - strncpy(led_expanded.effect, expanded_buf, sizeof(led_expanded.effect) - 1); + if (ts_action_expand_variables(led->effect, led_expanded.effect, sizeof(led_expanded.effect)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; } - + /* 使用展开后的结构 */ const ts_auto_action_led_t *led_final = &led_expanded; @@ -1092,11 +1146,29 @@ esp_err_t ts_action_exec_led(const ts_auto_action_led_t *led, break; } if (led_final->filter[0]) { + if (led_final->filter_params.present) { + cJSON *params = cJSON_CreateObject(); + if (!params || !cJSON_AddStringToObject(params, "device", device_name) || + !cJSON_AddStringToObject(params, "filter", led_final->filter) || + !ts_action_filter_api_params(&led_final->filter_params, params)) { + cJSON_Delete(params); + ret = ESP_ERR_NO_MEM; + break; + } + ts_api_result_t response = {0}; + ret = ts_api_call("led.filter.start", params, &response); + if (ret == ESP_OK && response.code != TS_API_OK) ret = ESP_FAIL; + ts_api_result_free(&response); + cJSON_Delete(params); + snprintf(result->output, sizeof(result->output), "LED filter: %s", led_final->filter); + break; + } char cmd[128]; if (strcmp(led_final->filter, "none") == 0 || strcmp(led_final->filter, "stop") == 0) { snprintf(cmd, sizeof(cmd), "led --stop-filter --device matrix"); } else { - snprintf(cmd, sizeof(cmd), "led --filter --device matrix --filter-name %s", led_final->filter); + snprintf(cmd, sizeof(cmd), "led --filter --device matrix --filter-name %.*s", + (int)sizeof(led_final->filter) - 1, led_final->filter); } ESP_LOGD(TAG, "Executing LED CLI (%u bytes)", (unsigned)strlen(cmd)); @@ -1240,7 +1312,10 @@ esp_err_t ts_action_exec_log(const ts_auto_action_log_t *log_action, /* Expand variables in message */ char expanded_msg[256]; - ts_action_expand_variables(log_action->message, expanded_msg, sizeof(expanded_msg)); + if (ts_action_expand_variables(log_action->message, expanded_msg, sizeof(expanded_msg)) < 0) { + result->status = TS_ACTION_STATUS_FAILED; + return ESP_ERR_INVALID_SIZE; + } /* Log at appropriate level */ switch (log_action->level) { @@ -1317,12 +1392,48 @@ esp_err_t ts_action_exec_device(const ts_auto_action_device_t *device, } static esp_err_t exec_ssh_ref_bound(const ts_auto_action_ssh_ref_t *, const action_binding_t *, - ts_action_result_t *); + uint32_t, ts_action_result_t *); +/* One direct launch may use the existing executor, independently of rule admission. */ +esp_err_t ts_action_service_control(const char *id, ts_service_operation_t kind, uint32_t *operation_id) { + if (!id || !id[0] || strlen(id) >= sizeof(((ts_auto_action_ssh_ref_t *)0)->cmd_id) || !operation_id) + return ESP_ERR_INVALID_ARG; + *operation_id = 0; + if (kind < TS_SERVICE_START || kind > TS_SERVICE_STOP) return ESP_ERR_INVALID_ARG; + if (!direct_service_admit()) return ESP_ERR_INVALID_STATE; + ts_action_queue_entry_t *entry = heap_caps_calloc(1, sizeof(*entry), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!entry) { direct_service_finished(); direct_service_finished(); return ESP_ERR_NO_MEM; } + entry->action.type = TS_AUTO_ACT_SSH_CMD_REF; + entry->service_kind = kind; + entry->enqueue_time = esp_timer_get_time() / 1000; + strcpy(entry->action.ssh_ref.cmd_id, id); + ts_ssh_binding_lock(); + esp_err_t ret = snapshot_command(&entry->action, kind == TS_SERVICE_START); + action_binding_t *binding = entry->action.runtime_binding; + if (ret == ESP_OK && (!binding->command.nohup || !binding->command.service_mode)) ret = ESP_ERR_INVALID_ARG; + if (ret == ESP_OK) { + ret = ts_ssh_service_reserve_operation(id, binding->registration, kind, &binding->control_id); + entry->service_operation_id = binding->control_id; + } + ts_ssh_binding_unlock(); + /* No semaphore, no new task, no queue wait in the HTTP handler. */ + if (ret == ESP_OK && xQueueSend(s_ctx->action_queue, entry, 0) != pdTRUE) ret = ESP_ERR_NO_MEM; + if (ret == ESP_OK) { + *operation_id = entry->service_operation_id; + /* Queue owns the snapshot from here; worker may already have finished. */ + } else { + if (entry->service_operation_id) ts_ssh_service_complete_operation(id, entry->service_operation_id, ret); + ts_action_snapshot_release(&entry->action); + direct_service_finished(); + } + free(entry); + direct_service_finished(); + return ret; +} esp_err_t ts_action_exec_ssh_ref(const ts_auto_action_ssh_ref_t *ref, ts_action_result_t *result) { - return exec_ssh_ref_bound(ref, NULL, result); + return exec_ssh_ref_bound(ref, NULL, 0, result); } static esp_err_t exec_ssh_ref_bound(const ts_auto_action_ssh_ref_t *ssh_ref, - const action_binding_t *binding, ts_action_result_t *result) { + const action_binding_t *binding, uint32_t launch_id, ts_action_result_t *result) { if (!ssh_ref || !result) { return ESP_ERR_INVALID_ARG; } @@ -1379,7 +1490,16 @@ static esp_err_t exec_ssh_ref_bound(const ts_auto_action_ssh_ref_t *ssh_ref, snprintf(result->output, sizeof(result->output), "Out of memory"); return ESP_ERR_NO_MEM; } - ts_action_expand_variables(cmd_config.command, expanded_cmd, TS_SSH_CMD_COMMAND_MAX); + /* Direct commands retain their literal text; only automation expands variables. */ + int expanded_length = binding && binding->control_id + ? snprintf(expanded_cmd, TS_SSH_CMD_COMMAND_MAX, "%s", cmd_config.command) + : ts_action_expand_variables(cmd_config.command, expanded_cmd, TS_SSH_CMD_COMMAND_MAX); + if (expanded_length < 0 || expanded_length >= TS_SSH_CMD_COMMAND_MAX) { + free(expanded_cmd); + result->status = TS_ACTION_STATUS_FAILED; + snprintf(result->output, sizeof(result->output), "Expanded command exceeds capacity"); + return ESP_ERR_INVALID_SIZE; + } /* Handle nohup mode: wrap command for background execution */ char *nohup_cmd = @@ -1484,7 +1604,7 @@ static esp_err_t exec_ssh_ref_bound(const ts_auto_action_ssh_ref_t *ssh_ref, uint32_t run_generation = 0; if (cmd_config.nohup && cmd_config.service_mode) { - ret = ts_ssh_service_begin(&cmd_config, session, &run_generation); + ret = ts_ssh_service_begin_reserved(&cmd_config, session, launch_id, &run_generation); if (ret != ESP_OK) { result->status = TS_ACTION_STATUS_FAILED; snprintf(result->output, sizeof(result->output), @@ -1708,6 +1828,23 @@ esp_err_t ts_action_exec_cli(const ts_auto_action_cli_t *cli, /* Internal Execute */ /*===========================================================================*/ +/* Reserve an automation launch when its action actually starts, not while the + * rule merely holds a configuration snapshot or waits for a later action. */ +static esp_err_t execute_bound_ref(const ts_auto_action_t *action, ts_action_result_t *result) { + const action_binding_t *binding = action->runtime_binding; + uint32_t launch_id = binding ? binding->control_id : 0; + bool own_reservation = binding && binding->command.nohup && binding->command.service_mode && !launch_id; + if (own_reservation) { + esp_err_t ret = ts_ssh_service_reserve_operation(binding->command.id, binding->registration, TS_SERVICE_START, &launch_id); + if (ret != ESP_OK) { result->status = TS_ACTION_STATUS_FAILED; return ret; } + } + esp_err_t ret = launch_id ? ts_ssh_service_operation_begin(binding->command.id, launch_id) : ESP_OK; + if (ret == ESP_OK) ret = exec_ssh_ref_bound(&action->ssh_ref, binding, launch_id, result); + else result->status = TS_ACTION_STATUS_FAILED; + if (own_reservation) ts_ssh_service_complete_operation(binding->command.id, launch_id, ret); + return ret; +} + static esp_err_t execute_action_internal(const ts_auto_action_t *action, ts_action_result_t *result) { @@ -1716,7 +1853,7 @@ static esp_err_t execute_action_internal(const ts_auto_action_t *action, return ts_action_exec_ssh(&action->ssh, result); case TS_AUTO_ACT_SSH_CMD_REF: - return exec_ssh_ref_bound(&action->ssh_ref, action->runtime_binding, result); + return execute_bound_ref(action, result); case TS_AUTO_ACT_CLI: return ts_action_exec_cli(&action->cli, result); @@ -1754,6 +1891,23 @@ static esp_err_t execute_action_internal(const ts_auto_action_t *action, /* Executor Task */ /*===========================================================================*/ +static esp_err_t execute_service_control(const ts_action_queue_entry_t *entry, ts_action_result_t *result) { + if (esp_timer_get_time() / 1000 - entry->enqueue_time >= SERVICE_QUEUE_TIMEOUT_MS) { + result->status = TS_ACTION_STATUS_TIMEOUT; + return ESP_ERR_TIMEOUT; /* Still queued: completion reports expired, never executes. */ + } + if (entry->service_kind == TS_SERVICE_START) return execute_action_internal(&entry->action, result); + esp_err_t ret = ts_ssh_service_operation_begin(entry->action.ssh_ref.cmd_id, entry->service_operation_id); + if (ret == ESP_OK) { + ts_ssh_service_status_t state; + ret = ts_ssh_service_execute_control(entry->action.ssh_ref.cmd_id, entry->service_operation_id, + entry->service_kind, &state); + } + result->status = ret == ESP_OK ? TS_ACTION_STATUS_SUCCESS : + ret == ESP_ERR_TIMEOUT ? TS_ACTION_STATUS_TIMEOUT : TS_ACTION_STATUS_FAILED; + return ret; +} + static void action_executor_task(void *arg) { ESP_LOGI(TAG, "Action executor task started (DRAM stack)"); @@ -1769,7 +1923,7 @@ static void action_executor_task(void *arg) if (entry.done_sem) xSemaphoreGive(entry.done_sem); completion_release(entry.completion); - action_finished(); + entry_finished(&entry, ESP_ERR_INVALID_STATE); break; } @@ -1784,10 +1938,15 @@ static void action_executor_task(void *arg) delay -= slice; } /* Stop discards work not yet started; active I/O owns its cleanup. */ - if (s_ctx->accepting) - execute_action_internal(&entry.action, result); + esp_err_t execution_error = ESP_ERR_INVALID_STATE; + if (entry.service_operation_id) + execution_error = execute_service_control(&entry, result); + else if (s_ctx->accepting) + execution_error = execute_action_internal(&entry.action, result); else result->status = TS_ACTION_STATUS_CANCELLED; + if (execution_error == ESP_OK && result->status != TS_ACTION_STATUS_SUCCESS) + execution_error = result->status == TS_ACTION_STATUS_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; /* Callback (async mode) */ if (entry.callback) { @@ -1811,7 +1970,7 @@ static void action_executor_task(void *arg) if (entry.done_sem) xSemaphoreGive(entry.done_sem); completion_release(entry.completion); - action_finished(); + entry_finished(&entry, execution_error); } } @@ -1870,77 +2029,63 @@ void ts_action_reset_stats(void) int ts_action_expand_variables(const char *input, char *output, size_t output_size) { - if (!input || !output || output_size == 0) { - return -1; - } - - const char *p = input; - char *out = output; - char *out_end = output + output_size - 1; - - while (*p && out < out_end) { - if (*p == '$' && *(p + 1) == '{') { - /* Find closing brace */ + if (!input || !output || !output_size) return -1; + size_t used = 0; + output[0] = 0; + for (const char *p = input; *p;) { + const char *text = p; + size_t length = 1, consumed = 1; + ts_auto_value_t value; + char formatted[64]; + if (p[0] == '$' && p[1] == '{') { const char *end = strchr(p + 2, '}'); if (end) { - /* Extract variable name */ - size_t name_len = end - (p + 2); - char var_name[64]; - if (name_len < sizeof(var_name)) { - memcpy(var_name, p + 2, name_len); - var_name[name_len] = '\0'; - - /* Get variable value */ - ts_auto_value_t value; - if (ts_variable_get(var_name, &value) == ESP_OK) { - /* Format value based on type */ - char val_str[64]; + consumed = length = (size_t)(end + 1 - p); + size_t name_length = (size_t)(end - p - 2); + char name[64]; + if (name_length < sizeof(name)) { + memcpy(name, p + 2, name_length); + name[name_length] = 0; + if (ts_variable_get(name, &value) == ESP_OK) { + int written = 0; switch (value.type) { + case TS_AUTO_VAL_STRING: + text = value.str_val; + length = strnlen(value.str_val, sizeof(value.str_val)); + break; case TS_AUTO_VAL_BOOL: - snprintf(val_str, sizeof(val_str), "%s", - value.bool_val ? "true" : "false"); + text = value.bool_val ? "true" : "false"; + length = strlen(text); break; case TS_AUTO_VAL_INT: - snprintf(val_str, sizeof(val_str), "%ld", - (long)value.int_val); + written = snprintf(formatted, sizeof(formatted), "%ld", (long)value.int_val); + text = formatted; + length = written < 0 ? sizeof(formatted) : (size_t)written; break; case TS_AUTO_VAL_FLOAT: - snprintf(val_str, sizeof(val_str), "%.2f", - value.float_val); - break; - case TS_AUTO_VAL_STRING: - strncpy(val_str, value.str_val, sizeof(val_str) - 1); + written = snprintf(formatted, sizeof(formatted), "%.2f", value.float_val); + text = formatted; + length = written < 0 ? sizeof(formatted) : (size_t)written; break; default: - val_str[0] = '\0'; - break; - } - - /* Copy value to output */ - size_t val_len = strlen(val_str); - if (out + val_len < out_end) { - memcpy(out, val_str, val_len); - out += val_len; - } - } else { - /* Variable not found, keep original */ - size_t orig_len = (end + 1) - p; - if (out + orig_len < out_end) { - memcpy(out, p, orig_len); - out += orig_len; + text = ""; + length = 0; } + if (text == formatted && length >= sizeof(formatted)) goto overflow; } } - p = end + 1; - continue; } } - - *out++ = *p++; - } - - *out = '\0'; - return out - output; + if (length > output_size - 1 - used) goto overflow; + memcpy(output + used, text, length); + used += length; + p += consumed; + } + output[used] = 0; + return (int)used; +overflow: + output[0] = 0; /* No caller may execute or publish a partial expansion. */ + return -1; } esp_err_t ts_action_parse_color(const char *color_str, uint8_t *r, uint8_t *g, uint8_t *b) @@ -2217,40 +2362,52 @@ esp_err_t ts_action_template_execute(const char *id, ts_action_result_t *result) return ret; } +static char *template_to_json(const ts_action_template_t *tpl); +static esp_err_t ensure_actions_dir(void); +static esp_err_t export_template_to_file(const ts_action_template_t *tpl); + static esp_err_t template_update_impl(const char *id, const ts_action_template_t *tpl) { - if (!s_ctx || !id || !tpl) { - return ESP_ERR_INVALID_ARG; - } - + if (!s_ctx || !id || !tpl || strcmp(id, tpl->id)) return ESP_ERR_INVALID_ARG; + ts_action_template_t *candidate = TS_MALLOC_PSRAM(sizeof(*candidate)); + if (!candidate) return ESP_ERR_NO_MEM; + esp_err_t ret = ESP_ERR_NOT_FOUND; xSemaphoreTake(s_ctx->templates_mutex, portMAX_DELAY); - for (int i = 0; i < s_ctx->template_count; i++) { - if (strcmp(s_ctx->templates[i].id, id) == 0) { - /* Preserve some fields */ - int64_t created_at = s_ctx->templates[i].created_at; - uint32_t use_count = s_ctx->templates[i].use_count; - int64_t last_used = s_ctx->templates[i].last_used_at; - - /* Update template */ - memcpy(&s_ctx->templates[i], tpl, sizeof(ts_action_template_t)); - - /* Restore preserved fields */ - s_ctx->templates[i].created_at = created_at; - s_ctx->templates[i].use_count = use_count; - s_ctx->templates[i].last_used_at = last_used; - - xSemaphoreGive(s_ctx->templates_mutex); - - /* Save to NVS */ - ts_action_templates_save(); - - ESP_LOGI(TAG, "Updated action template: %s", id); - return ESP_OK; + if (strcmp(s_ctx->templates[i].id, id)) continue; + *candidate = *tpl; + candidate->created_at = s_ctx->templates[i].created_at; + candidate->use_count = s_ctx->templates[i].use_count; + candidate->last_used_at = s_ctx->templates[i].last_used_at; + char *json = template_to_json(candidate); + if (!json) { ret = ESP_ERR_NO_MEM; break; } + /* Follow the same authority as the loader: SD first when mounted, + * otherwise NVS. A failed SD write must not advance the NVS backup. */ + bool sd_authority = ts_storage_sd_mounted(); + ret = ESP_OK; + if (sd_authority) { + ret = ensure_actions_dir(); + if (ret == ESP_OK) ret = ts_action_store_save(id, json, true); + } + if (ret == ESP_OK) { + nvs_handle_t handle; + esp_err_t backup = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + if (backup == ESP_OK) { + char key[16]; + snprintf(key, sizeof(key), "%s%d", NVS_KEY_PREFIX, i); + backup = nvs_set_str(handle, key, json); + if (backup == ESP_OK) backup = nvs_commit(handle); + nvs_close(handle); + } + if (!sd_authority) ret = backup; + else if (backup != ESP_OK) ESP_LOGW(TAG, "Template saved to SD; NVS backup failed: %s", esp_err_to_name(backup)); } + free(json); + if (ret == ESP_OK) s_ctx->templates[i] = *candidate; + break; } - xSemaphoreGive(s_ctx->templates_mutex); - return ESP_ERR_NOT_FOUND; + free(candidate); + return ret; } /*===========================================================================*/ @@ -2302,32 +2459,36 @@ static char *template_to_json(const ts_action_template_t *tpl) cJSON *root = cJSON_CreateObject(); if (!root) return NULL; - cJSON_AddStringToObject(root, "id", tpl->id); - cJSON_AddStringToObject(root, "name", tpl->name); - cJSON_AddStringToObject(root, "description", tpl->description); - cJSON_AddBoolToObject(root, "enabled", tpl->enabled); - cJSON_AddStringToObject(root, "type", action_type_to_str(tpl->action.type)); - cJSON_AddNumberToObject(root, "delay_ms", tpl->action.delay_ms); - cJSON_AddNumberToObject(root, "created_at", tpl->created_at); - cJSON_AddNumberToObject(root, "use_count", tpl->use_count); + if (!cJSON_AddStringToObject(root, "id", tpl->id)) goto failed; + if (!cJSON_AddStringToObject(root, "name", tpl->name)) goto failed; + if (!cJSON_AddStringToObject(root, "description", tpl->description)) goto failed; + if (!cJSON_AddBoolToObject(root, "enabled", tpl->enabled)) goto failed; + if (!cJSON_AddBoolToObject(root, "async", tpl->async)) goto failed; + if (!cJSON_AddStringToObject(root, "type", action_type_to_str(tpl->action.type))) goto failed; + if (!cJSON_AddNumberToObject(root, "delay_ms", tpl->action.delay_ms)) goto failed; + if (!cJSON_AddNumberToObject(root, "created_at", tpl->created_at)) goto failed; + if (!cJSON_AddNumberToObject(root, "use_count", tpl->use_count)) goto failed; /* Serialize type-specific data */ switch (tpl->action.type) { case TS_AUTO_ACT_CLI: { cJSON *cli = cJSON_AddObjectToObject(root, "cli"); - cJSON_AddStringToObject(cli, "command", tpl->action.cli.command); - cJSON_AddStringToObject(cli, "var_name", tpl->action.cli.var_name); - cJSON_AddNumberToObject(cli, "timeout_ms", tpl->action.cli.timeout_ms); + if (!cli) goto failed; + if (!cJSON_AddStringToObject(cli, "command", tpl->action.cli.command)) goto failed; + if (!cJSON_AddStringToObject(cli, "var_name", tpl->action.cli.var_name)) goto failed; + if (!cJSON_AddNumberToObject(cli, "timeout_ms", tpl->action.cli.timeout_ms)) goto failed; break; } case TS_AUTO_ACT_SSH_CMD_REF: { cJSON *ssh_ref = cJSON_AddObjectToObject(root, "ssh_ref"); - cJSON_AddStringToObject(ssh_ref, "cmd_id", tpl->action.ssh_ref.cmd_id); + if (!ssh_ref) goto failed; + if (!cJSON_AddStringToObject(ssh_ref, "cmd_id", tpl->action.ssh_ref.cmd_id)) goto failed; break; } case TS_AUTO_ACT_LED: { cJSON *led = cJSON_AddObjectToObject(root, "led"); - cJSON_AddStringToObject(led, "device", tpl->action.led.device); + if (!led) goto failed; + if (!cJSON_AddStringToObject(led, "device", tpl->action.led.device)) goto failed; /* 控制类型 */ const char *ctrl_type_str = "fill"; switch (tpl->action.led.ctrl_type) { @@ -2342,71 +2503,75 @@ static char *template_to_json(const ts_action_template_t *tpl) case TS_LED_CTRL_TEXT_STOP: ctrl_type_str = "text_stop"; break; default: ctrl_type_str = "fill"; break; } - cJSON_AddStringToObject(led, "ctrl_type", ctrl_type_str); - cJSON_AddNumberToObject(led, "index", tpl->action.led.index); + if (!cJSON_AddStringToObject(led, "ctrl_type", ctrl_type_str)) goto failed; + if (!cJSON_AddNumberToObject(led, "index", tpl->action.led.index)) goto failed; /* 颜色输出为 hex */ char color_hex[8]; snprintf(color_hex, sizeof(color_hex), "#%02X%02X%02X", tpl->action.led.r, tpl->action.led.g, tpl->action.led.b); - cJSON_AddStringToObject(led, "color", color_hex); - cJSON_AddNumberToObject(led, "brightness", tpl->action.led.brightness); + if (!cJSON_AddStringToObject(led, "color", color_hex)) goto failed; + if (!cJSON_AddNumberToObject(led, "brightness", tpl->action.led.brightness)) goto failed; if (tpl->action.led.effect[0]) { - cJSON_AddStringToObject(led, "effect", tpl->action.led.effect); + if (!cJSON_AddStringToObject(led, "effect", tpl->action.led.effect)) goto failed; } - cJSON_AddNumberToObject(led, "speed", tpl->action.led.speed); - cJSON_AddNumberToObject(led, "duration_ms", tpl->action.led.duration_ms); + if (!cJSON_AddNumberToObject(led, "speed", tpl->action.led.speed)) goto failed; + if (!cJSON_AddNumberToObject(led, "duration_ms", tpl->action.led.duration_ms)) goto failed; /* Matrix 高级功能 */ if (tpl->action.led.text[0]) { - cJSON_AddStringToObject(led, "text", tpl->action.led.text); + if (!cJSON_AddStringToObject(led, "text", tpl->action.led.text)) goto failed; } if (tpl->action.led.font[0]) { - cJSON_AddStringToObject(led, "font", tpl->action.led.font); + if (!cJSON_AddStringToObject(led, "font", tpl->action.led.font)) goto failed; } if (tpl->action.led.image_path[0]) { - cJSON_AddStringToObject(led, "image_path", tpl->action.led.image_path); + if (!cJSON_AddStringToObject(led, "image_path", tpl->action.led.image_path)) goto failed; } if (tpl->action.led.qr_text[0]) { - cJSON_AddStringToObject(led, "qr_text", tpl->action.led.qr_text); + if (!cJSON_AddStringToObject(led, "qr_text", tpl->action.led.qr_text)) goto failed; } if (tpl->action.led.qr_ecc) { char ecc_str[2] = { tpl->action.led.qr_ecc, '\0' }; - cJSON_AddStringToObject(led, "qr_ecc", ecc_str); + if (!cJSON_AddStringToObject(led, "qr_ecc", ecc_str)) goto failed; } if (tpl->action.led.filter[0]) { - cJSON_AddStringToObject(led, "filter", tpl->action.led.filter); + if (!cJSON_AddStringToObject(led, "filter", tpl->action.led.filter)) goto failed; } - cJSON_AddBoolToObject(led, "center", tpl->action.led.center); - cJSON_AddBoolToObject(led, "loop", tpl->action.led.loop); + if (!ts_action_filter_encode(&tpl->action.led.filter_params, led)) goto failed; + if (!cJSON_AddBoolToObject(led, "center", tpl->action.led.center)) goto failed; + if (!cJSON_AddBoolToObject(led, "loop", tpl->action.led.loop)) goto failed; if (tpl->action.led.scroll[0]) { - cJSON_AddStringToObject(led, "scroll", tpl->action.led.scroll); + if (!cJSON_AddStringToObject(led, "scroll", tpl->action.led.scroll)) goto failed; } if (tpl->action.led.align[0]) { - cJSON_AddStringToObject(led, "align", tpl->action.led.align); + if (!cJSON_AddStringToObject(led, "align", tpl->action.led.align)) goto failed; } - cJSON_AddNumberToObject(led, "x", tpl->action.led.x); - cJSON_AddNumberToObject(led, "y", tpl->action.led.y); + if (!cJSON_AddNumberToObject(led, "x", tpl->action.led.x)) goto failed; + if (!cJSON_AddNumberToObject(led, "y", tpl->action.led.y)) goto failed; break; } case TS_AUTO_ACT_LOG: { cJSON *log_obj = cJSON_AddObjectToObject(root, "log"); - cJSON_AddNumberToObject(log_obj, "level", tpl->action.log.level); - cJSON_AddStringToObject(log_obj, "message", tpl->action.log.message); + if (!log_obj) goto failed; + if (!cJSON_AddNumberToObject(log_obj, "level", tpl->action.log.level)) goto failed; + if (!cJSON_AddStringToObject(log_obj, "message", tpl->action.log.message)) goto failed; break; } case TS_AUTO_ACT_SET_VAR: { cJSON *set_var = cJSON_AddObjectToObject(root, "set_var"); - cJSON_AddStringToObject(set_var, "variable", tpl->action.set_var.variable); + if (!set_var) goto failed; + if (!cJSON_AddStringToObject(set_var, "variable", tpl->action.set_var.variable)) goto failed; /* Store value as string for simplicity */ if (tpl->action.set_var.value.type == TS_AUTO_VAL_STRING) { - cJSON_AddStringToObject(set_var, "value", tpl->action.set_var.value.str_val); + if (!cJSON_AddStringToObject(set_var, "value", tpl->action.set_var.value.str_val)) goto failed; } break; } case TS_AUTO_ACT_WEBHOOK: { cJSON *webhook = cJSON_AddObjectToObject(root, "webhook"); - cJSON_AddStringToObject(webhook, "url", tpl->action.webhook.url); - cJSON_AddStringToObject(webhook, "method", tpl->action.webhook.method); - cJSON_AddStringToObject(webhook, "body_template", tpl->action.webhook.body_template); + if (!webhook) goto failed; + if (!cJSON_AddStringToObject(webhook, "url", tpl->action.webhook.url)) goto failed; + if (!cJSON_AddStringToObject(webhook, "method", tpl->action.webhook.method)) goto failed; + if (!cJSON_AddStringToObject(webhook, "body_template", tpl->action.webhook.body_template)) goto failed; break; } default: @@ -2416,6 +2581,9 @@ static char *template_to_json(const ts_action_template_t *tpl) char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); return json; +failed: + cJSON_Delete(root); + return NULL; } /* Forward declarations for SD card loading */ @@ -2458,25 +2626,11 @@ static esp_err_t export_template_to_file(const ts_action_template_t *tpl) { if (!tpl || !tpl->id[0]) return ESP_ERR_INVALID_ARG; - char filepath[128]; - snprintf(filepath, sizeof(filepath), "%s/%s.json", ACTIONS_SDCARD_DIR, tpl->id); - char *json = template_to_json(tpl); if (!json) return ESP_ERR_NO_MEM; - - FILE *fp = fopen(filepath, "w"); - if (!fp) { - free(json); - ESP_LOGE(TAG, "Failed to open file: %s", filepath); - return ESP_FAIL; - } - - fprintf(fp, "%s\n", json); - fclose(fp); + esp_err_t ret = ts_action_store_save(tpl->id, json, false); free(json); - - ESP_LOGD(TAG, "Exported template to %s", filepath); - return ESP_OK; + return ret; } /** @@ -2530,6 +2684,9 @@ static esp_err_t load_templates_from_dir(void) if (!is_json && !is_tscfg) { continue; } + + size_t id_length = len - (is_tscfg ? 6 : 5); + if (id_length >= TS_AUTO_NAME_MAX_LEN) continue; /* 对于 .json 文件,检查是否存在对应的 .tscfg(跳过以使用加密版本) */ if (is_json) { @@ -2544,18 +2701,9 @@ static esp_err_t load_templates_from_dir(void) } } - /* 限制文件名长度避免缓冲区溢出 */ - if (len > 60) { - continue; - } - char filepath[128]; - if (is_tscfg) { - snprintf(filepath, sizeof(filepath), "%s/%.*s.json", - ACTIONS_SDCARD_DIR, (int)(len - 6), entry->d_name); - } else { - snprintf(filepath, sizeof(filepath), "%s/%.60s", ACTIONS_SDCARD_DIR, entry->d_name); - } + snprintf(filepath, sizeof(filepath), "%s/%.*s.json", + ACTIONS_SDCARD_DIR, (int)id_length, entry->d_name); /* 使用 .tscfg 优先加载 */ char *content = NULL; @@ -2571,7 +2719,8 @@ static esp_err_t load_templates_from_dir(void) /* 解析并添加 */ memset(tpl, 0, sizeof(ts_action_template_t)); - if (json_to_template(content, tpl) == ESP_OK && tpl->id[0]) { + esp_err_t parsed = json_to_template(content, tpl); + if (parsed == ESP_OK) { if (s_ctx->template_count < TS_ACTION_TEMPLATE_MAX) { memcpy(&s_ctx->templates[s_ctx->template_count], tpl, sizeof(ts_action_template_t)); s_ctx->template_count++; @@ -2579,7 +2728,7 @@ static esp_err_t load_templates_from_dir(void) ESP_LOGD(TAG, "Loaded template from file: %s%s", tpl->id, used_tscfg ? " (encrypted)" : ""); } - } + } else ESP_LOGE(TAG, "Invalid action template in %s: %s", filepath, esp_err_to_name(parsed)); free(content); } @@ -2608,14 +2757,17 @@ static esp_err_t export_all_templates_to_dir(void) if (ret != ESP_OK) return ret; int exported = 0; + xSemaphoreTake(s_ctx->templates_mutex, portMAX_DELAY); for (int i = 0; i < s_ctx->template_count; i++) { - if (export_template_to_file(&s_ctx->templates[i]) == ESP_OK) { + ret = export_template_to_file(&s_ctx->templates[i]); + if (ret == ESP_OK) { exported++; - } + } else break; } + xSemaphoreGive(s_ctx->templates_mutex); ESP_LOGI(TAG, "Exported %d templates to directory: %s", exported, ACTIONS_SDCARD_DIR); - return ESP_OK; + return ret; } /** @@ -2625,6 +2777,24 @@ static esp_err_t json_to_template(const char *json, ts_action_template_t *tpl) { cJSON *root = cJSON_Parse(json); if (!root) return ESP_ERR_INVALID_ARG; + cJSON *owner = root; + const cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "type"); + bool envelope = cJSON_IsString(kind) && !strcmp(kind->valuestring, "action_template"); + if (envelope) { + root = cJSON_GetObjectItemCaseSensitive(owner, "template"); + } + const cJSON *id = cJSON_GetObjectItemCaseSensitive(root, "id"); + const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type"); + if (!cJSON_IsObject(root) || !cJSON_IsString(id) || !id->valuestring[0] || + strlen(id->valuestring) >= sizeof(tpl->id) || + (envelope && !cJSON_IsString(type)) || + (type && (!cJSON_IsString(type) || + strcmp(action_type_to_str(str_to_action_type(type->valuestring)), type->valuestring))) || + (cJSON_IsString(type) && !strcmp(type->valuestring, "led") && + !cJSON_IsObject(cJSON_GetObjectItemCaseSensitive(root, "led")))) { + cJSON_Delete(owner); + return ESP_ERR_INVALID_ARG; + } memset(tpl, 0, sizeof(ts_action_template_t)); @@ -2646,6 +2816,9 @@ static esp_err_t json_to_template(const char *json, ts_action_template_t *tpl) if ((item = cJSON_GetObjectItem(root, "type")) && cJSON_IsString(item)) { tpl->action.type = str_to_action_type(item->valuestring); } + if ((item = cJSON_GetObjectItem(root, "async")) && cJSON_IsBool(item)) { + tpl->async = cJSON_IsTrue(item); + } if ((item = cJSON_GetObjectItem(root, "delay_ms")) && cJSON_IsNumber(item)) { tpl->action.delay_ms = (uint16_t)item->valueint; } @@ -2768,6 +2941,11 @@ static esp_err_t json_to_template(const char *json, ts_action_template_t *tpl) strncpy(tpl->action.led.filter, item->valuestring, sizeof(tpl->action.led.filter) - 1); } + if (ts_action_filter_decode(cJSON_GetObjectItemCaseSensitive(led, "filter_params"), + &tpl->action.led.filter_params) != ESP_OK) { + cJSON_Delete(owner); + return ESP_ERR_INVALID_ARG; + } if ((item = cJSON_GetObjectItem(led, "center")) && cJSON_IsBool(item)) { tpl->action.led.center = cJSON_IsTrue(item); } @@ -2841,7 +3019,7 @@ static esp_err_t json_to_template(const char *json, ts_action_template_t *tpl) break; } - cJSON_Delete(root); + cJSON_Delete(owner); return ESP_OK; } @@ -2922,7 +3100,15 @@ esp_err_t ts_action_templates_load(void) /* 1. 优先从 SD 卡独立文件目录加载 */ if (ts_storage_sd_mounted()) { + xSemaphoreTake(s_ctx->templates_mutex, portMAX_DELAY); + ret = ts_action_store_recover(); + if (ret != ESP_OK) { + xSemaphoreGive(s_ctx->templates_mutex); + ESP_LOGE(TAG, "Action template storage recovery failed: %s", esp_err_to_name(ret)); + return ret; + } ret = load_templates_from_dir(); + xSemaphoreGive(s_ctx->templates_mutex); if (ret == ESP_OK && s_ctx->template_count > 0) { ESP_LOGI(TAG, "Loaded %d action templates from SD card directory", s_ctx->template_count); loaded_from_sdcard = true; diff --git a/components/ts_automation/src/ts_action_store.c b/components/ts_automation/src/ts_action_store.c new file mode 100644 index 00000000..8ab6b0ca --- /dev/null +++ b/components/ts_automation/src/ts_action_store.c @@ -0,0 +1,180 @@ +#include "ts_action_store.h" +#include "ts_automation_types.h" +#include "cJSON.h" +#include "esp_log.h" +#include +#include +#include +#include +#include +#include +#include + +#ifndef TS_ACTIONS_DIR +#define TS_ACTIONS_DIR "/sdcard/config/actions" +#endif +static const char *TAG = "action_store"; +#define PATH_CAPACITY (sizeof(TS_ACTIONS_DIR) + TS_AUTO_NAME_MAX_LEN + sizeof(".tscfg.previous") + 2) + +typedef struct { + char json[PATH_CAPACITY], pack[PATH_CAPACITY], pending[PATH_CAPACITY]; + char old_json[PATH_CAPACITY], old_pack[PATH_CAPACITY]; +} paths_t; + +static bool paths(const char *id, paths_t *p) +{ + if (!id || !*id || strlen(id) >= TS_AUTO_NAME_MAX_LEN || strchr(id, '/') || + !strcmp(id, ".") || !strcmp(id, "..")) return false; + snprintf(p->json, sizeof(p->json), TS_ACTIONS_DIR "/%s.json", id); + snprintf(p->pack, sizeof(p->pack), TS_ACTIONS_DIR "/%s.tscfg", id); + snprintf(p->pending, sizeof(p->pending), TS_ACTIONS_DIR "/.%s.pending", id); + snprintf(p->old_json, sizeof(p->old_json), TS_ACTIONS_DIR "/.%s.json.previous", id); + snprintf(p->old_pack, sizeof(p->old_pack), TS_ACTIONS_DIR "/.%s.tscfg.previous", id); + return true; +} + +static int exists(const char *path) +{ + struct stat st; + if (stat(path, &st) == 0) return 1; + return errno == ENOENT ? 0 : -1; +} + +static bool remove_file(const char *path) +{ + return unlink(path) == 0 || errno == ENOENT; +} + +static bool valid_json(const char *text, const char *id) +{ + cJSON *root = cJSON_Parse(text); + const cJSON *name = cJSON_GetObjectItemCaseSensitive(root, "id"); + const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type"); + bool ok = cJSON_IsObject(root) && cJSON_IsString(name) && !strcmp(name->valuestring, id) && + cJSON_IsString(type); + cJSON_Delete(root); + return ok; +} + +static bool valid_file(const char *path, const char *id) +{ + FILE *file = fopen(path, "rb"); + if (!file) return false; + bool ok = fseek(file, 0, SEEK_END) == 0; + long size = ok ? ftell(file) : -1; + if (size < 0 || fseek(file, 0, SEEK_SET) != 0) ok = false; + char *text = ok ? malloc((size_t)size + 1) : NULL; + if (!text) ok = false; + if (ok) { + ok = fread(text, 1, (size_t)size, file) == (size_t)size && !ferror(file); + text[size] = 0; + } + if (fclose(file) != 0) ok = false; + if (ok) ok = valid_json(text, id); + free(text); + return ok; +} + +static bool write_file(const char *path, const char *text) +{ + FILE *file = fopen(path, "wb"); + if (!file) return false; + size_t size = strlen(text); + bool ok = fwrite(text, 1, size, file) == size; + if (fflush(file) != 0 || fsync(fileno(file)) != 0) ok = false; + if (fclose(file) != 0) ok = false; + return ok; +} + +static bool restore(const char *old, const char *target) +{ + int present = exists(old); + if (present < 0) return false; + return !present || (remove_file(target) && rename(old, target) == 0); +} + +static esp_err_t recover_one(const char *id, const paths_t *p, bool for_write) +{ + int pending = exists(p->pending), old_json = exists(p->old_json), old_pack = exists(p->old_pack); + if (pending < 0 || old_json < 0 || old_pack < 0) return ESP_FAIL; + if (!pending && !old_json && !old_pack) return ESP_OK; + + /* Pending remains until the final rename, so it also records rollback + * intent. Remove it only after BOTH original files have been restored. */ + if (pending) { + if (!restore(p->old_json, p->json) || !restore(p->old_pack, p->pack) || + !remove_file(p->pending)) return ESP_FAIL; + return ESP_OK; + } + + int target = exists(p->json); + if (target < 0) return ESP_FAIL; + if (!target) { + /* A missing new file cannot win over a complete backup. + * Persist rollback intent before restoring either member. */ + if (!write_file(p->pending, "") || !restore(p->old_json, p->json) || + !restore(p->old_pack, p->pack) || !remove_file(p->pending)) return ESP_FAIL; + return ESP_OK; + } + /* A read/allocation failure is not evidence that the commit failed. + * Retain both versions until the committed file can be inspected. */ + if (!valid_file(p->json, id)) return ESP_FAIL; + /* Final JSON is complete and pending is gone: the replacement committed. + * Cleanup failure cannot undo it, but must block another replacement. */ + bool cleaned = remove_file(p->old_json) && remove_file(p->old_pack); + if (!cleaned) { + ESP_LOGW(TAG, "Committed template %s has backup cleanup pending", id); + return for_write ? ESP_FAIL : ESP_OK; + } + return ESP_OK; +} + +esp_err_t ts_action_store_save(const char *id, const char *json, bool replace_pack) +{ + paths_t p; + if (!paths(id, &p) || !json || !valid_json(json, id)) return ESP_ERR_INVALID_ARG; + esp_err_t ret = recover_one(id, &p, true); + if (ret != ESP_OK) return ret; + if (!write_file(p.pending, json)) goto rollback; + int had_json = exists(p.json), had_pack = replace_pack ? exists(p.pack) : 0; + if (had_json < 0 || had_pack < 0) goto rollback; + if (had_json && rename(p.json, p.old_json) != 0) goto rollback; + if (had_pack && rename(p.pack, p.old_pack) != 0) goto rollback; + if (rename(p.pending, p.json) != 0) { + /* Some I/O errors report after the operation applied. Observe the + * actual rename result before claiming the old version survived. */ + if (exists(p.pending) != 0 || !valid_file(p.json, id)) goto rollback; + } + (void)recover_one(id, &p, false); + return ESP_OK; +rollback: + if (recover_one(id, &p, false) != ESP_OK) ESP_LOGE(TAG, "Template %s needs storage recovery", id); + return ESP_FAIL; +} + +esp_err_t ts_action_store_recover(void) +{ + DIR *dir = opendir(TS_ACTIONS_DIR); + if (!dir) return errno == ENOENT ? ESP_OK : ESP_FAIL; + esp_err_t ret = ESP_OK; + struct dirent *entry; + const char *suffixes[] = {".pending", ".json.previous", ".tscfg.previous"}; + while ((entry = readdir(dir))) { + if (entry->d_name[0] != '.') continue; + size_t length = strlen(entry->d_name); + for (size_t i = 0; i < 3; i++) { + size_t suffix = strlen(suffixes[i]); + if (length <= suffix + 1 || strcmp(entry->d_name + length - suffix, suffixes[i])) continue; + size_t id_length = length - suffix - 1; + if (id_length >= TS_AUTO_NAME_MAX_LEN) { ret = ESP_ERR_INVALID_ARG; break; } + char id[TS_AUTO_NAME_MAX_LEN]; + memcpy(id, entry->d_name + 1, id_length); id[id_length] = 0; + paths_t p; + ret = paths(id, &p) ? recover_one(id, &p, false) : ESP_ERR_INVALID_ARG; + break; + } + if (ret != ESP_OK) break; + } + if (closedir(dir) != 0 && ret == ESP_OK) ret = ESP_FAIL; + return ret; +} diff --git a/components/ts_automation/src/ts_action_store.h b/components/ts_automation/src/ts_action_store.h new file mode 100644 index 00000000..849a5f71 --- /dev/null +++ b/components/ts_automation/src/ts_action_store.h @@ -0,0 +1,7 @@ +#pragma once +#include "esp_err.h" +#include + +/* Calls are serialized by the action manager's template mutex. */ +esp_err_t ts_action_store_save(const char *id, const char *json, bool replace_pack); +esp_err_t ts_action_store_recover(void); diff --git a/components/ts_automation/src/ts_rule_codec.c b/components/ts_automation/src/ts_rule_codec.c index 68ca25f7..681e954b 100644 --- a/components/ts_automation/src/ts_rule_codec.c +++ b/components/ts_automation/src/ts_rule_codec.c @@ -1,4 +1,5 @@ #include "ts_rule_codec.h" +#include "ts_action_filter.h" #include "esp_heap_caps.h" #include "ts_action_manager.h" #include @@ -401,6 +402,10 @@ esp_err_t ts_rule_decode(const cJSON *j, ts_auto_rule_t *r) { if (rep < 0) goto invalid; out->repeat_mode = rep; + if (out->type == TS_AUTO_ACT_LED && !out->template_id[0] && + ts_action_filter_decode(cJSON_GetObjectItemCaseSensitive(a, "filter_params"), + &out->led.filter_params) != ESP_OK) + goto invalid; if (cJSON_HasObjectItem(a, "condition")) { const cJSON *ac = cJSON_GetObjectItemCaseSensitive(a, "condition"); out->condition.has_condition = !cJSON_IsNull(ac); @@ -473,6 +478,8 @@ cJSON *ts_rule_encode(const ts_auto_rule_t *r) { !fields_write(aj, a, by_type[a->type].fields, by_type[a->type].count)) || !cJSON_AddStringToObject(aj, "repeat_mode", repeats[a->repeat_mode])) goto fail; + if (a->type == TS_AUTO_ACT_LED && !ts_action_filter_encode(&a->led.filter_params, aj)) + goto fail; if (a->condition.has_condition) { cJSON *cj = condition_write(a->condition.variable, a->condition.op, &a->condition.value); diff --git a/components/ts_automation/src/ts_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index 67bf7d3c..e7f9060c 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -1344,10 +1344,7 @@ esp_err_t ts_action_execute(const ts_auto_action_t *action) ts_action_result_t result = {0}; if (action->runtime_snapshot) { - ts_auto_action_t queued = *action; - queued.delay_ms = 0; /* Rule sequencer already applied this delay. */ - return action->async ? ts_action_queue(&queued, NULL, NULL, 5) - : ts_action_manager_execute(&queued, &result); + return ts_action_submit_prepared(action); } /* 如果有 template_id,使用模板执行(模板包含完整的动作数据) */ if (action->template_id[0] != '\0') { diff --git a/components/ts_cert/CMakeLists.txt b/components/ts_cert/CMakeLists.txt index 3f8ea7c7..06b981cd 100644 --- a/components/ts_cert/CMakeLists.txt +++ b/components/ts_cert/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register( SRCS "src/ts_cert.c" "src/ts_cert_time.c" + "src/ts_cert_subject.c" INCLUDE_DIRS "include" REQUIRES diff --git a/components/ts_cert/include/ts_cert_subject.h b/components/ts_cert/include/ts_cert_subject.h new file mode 100644 index 00000000..2993da11 --- /dev/null +++ b/components/ts_cert/include/ts_cert_subject.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include "esp_err.h" + +#define TS_CERT_SUBJECT_CAPACITY 256 +#define TS_CERT_DEFAULT_DEVICE_ID "TIANSHAN-DEVICE-001" + +/* Build the MbedTLS DN without letting field contents become DN syntax. */ +esp_err_t ts_cert_build_subject(const char *device_id, const char *organization, + const char *org_unit, char *out, size_t capacity); diff --git a/components/ts_cert/src/ts_cert.c b/components/ts_cert/src/ts_cert.c index 21afc725..577d7239 100644 --- a/components/ts_cert/src/ts_cert.c +++ b/components/ts_cert/src/ts_cert.c @@ -13,6 +13,7 @@ */ #include "ts_cert.h" +#include "ts_cert_subject.h" #include "ts_cert_time.h" #include "ts_event.h" #include "freertos/semphr.h" @@ -503,17 +504,10 @@ static esp_err_t ts_cert_generate_csr_locked(const ts_cert_csr_opts_t *opts, mbedtls_x509write_csr_set_md_alg(&csr, MBEDTLS_MD_SHA256); /* Build subject DN */ - char subject[256]; - int subject_len = snprintf(subject, sizeof(subject), "CN=%s", opts->device_id); - - if (opts->organization && strlen(opts->organization) > 0) { - subject_len += snprintf(subject + subject_len, sizeof(subject) - subject_len, - ",O=%s", opts->organization); - } - if (opts->org_unit && strlen(opts->org_unit) > 0) { - subject_len += snprintf(subject + subject_len, sizeof(subject) - subject_len, - ",OU=%s", opts->org_unit); - } + char subject[TS_CERT_SUBJECT_CAPACITY]; + err = ts_cert_build_subject(opts->device_id, opts->organization, opts->org_unit, + subject, sizeof(subject)); + if (err != ESP_OK) goto cleanup; ret = mbedtls_x509write_csr_set_subject_name(&csr, subject); if (ret != 0) { @@ -592,7 +586,7 @@ static esp_err_t ts_cert_generate_csr_default_locked(char *csr_pem, size_t *csr_ } /* TODO: Get device ID from configuration */ - const char *device_id = "TIANSHAN-DEVICE-001"; + const char *device_id = TS_CERT_DEFAULT_DEVICE_ID; ts_cert_csr_opts_t opts = { .device_id = device_id, diff --git a/components/ts_cert/src/ts_cert_subject.c b/components/ts_cert/src/ts_cert_subject.c new file mode 100644 index 00000000..eb468c35 --- /dev/null +++ b/components/ts_cert/src/ts_cert_subject.c @@ -0,0 +1,63 @@ +#include "ts_cert_subject.h" +#include "ts_cert.h" +#include +#include + +static bool append(char *out, size_t capacity, size_t *used, const char *text, size_t len) +{ + if (*used >= capacity || len >= capacity - *used) return false; + memcpy(out + *used, text, len); + *used += len; + out[*used] = '\0'; + return true; +} + +static bool append_value(char *out, size_t capacity, size_t *used, const char *value) +{ + size_t len = strlen(value); + bool encoded = value[0] == ' ' || value[0] == '#' || value[len - 1] == ' '; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)value[i]; + if (c < 32 || c == 127 || strchr(",=+<>;\"\\", c)) encoded = true; + } + if (!encoded) return append(out, capacity, used, value, len); + + /* MbedTLS accepts RFC4514 hexstrings containing DER. This also handles a + * trailing backslash correctly; its text-DN parser checks only the byte + * before a comma, rather than the parity of preceding backslashes. */ + if (len > 255) return false; + static const char hex[] = "0123456789ABCDEF"; + unsigned char header[] = {0x0c, (unsigned char)len}; /* UTF8String */ + if (!append(out, capacity, used, "#", 1)) return false; + for (size_t i = 0; i < sizeof(header); i++) { + char pair[] = {hex[header[i] >> 4], hex[header[i] & 15]}; + if (i == 1 && len >= 128 && !append(out, capacity, used, "81", 2)) return false; + if (!append(out, capacity, used, pair, 2)) return false; + } + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)value[i]; + char pair[] = {hex[c >> 4], hex[c & 15]}; + if (!append(out, capacity, used, pair, 2)) return false; + } + return true; +} + +esp_err_t ts_cert_build_subject(const char *device_id, const char *organization, + const char *org_unit, char *out, size_t capacity) +{ + if (!out || !capacity || !device_id || !*device_id) return ESP_ERR_INVALID_ARG; + out[0] = '\0'; + if (strlen(device_id) >= TS_CERT_DEVICE_ID_MAX_LEN) return ESP_ERR_INVALID_SIZE; + const char *names[] = {"CN=", ",O=", ",OU="}; + const char *values[] = {device_id, organization, org_unit}; + size_t used = 0; + for (size_t i = 0; i < 3; i++) { + if (!values[i] || !*values[i]) continue; + if (!append(out, capacity, &used, names[i], strlen(names[i])) || + !append_value(out, capacity, &used, values[i])) { + out[0] = '\0'; + return ESP_ERR_INVALID_SIZE; + } + } + return ESP_OK; +} diff --git a/components/ts_console/commands/ts_cmd_fan.c b/components/ts_console/commands/ts_cmd_fan.c index 9ae1fcf4..54e6096a 100644 --- a/components/ts_console/commands/ts_cmd_fan.c +++ b/components/ts_console/commands/ts_cmd_fan.c @@ -118,10 +118,13 @@ static int do_fan_status(int fan_id, bool json) ts_console_printf("Fan %d:\n", fan_id); ts_console_printf(" Enabled: %s\n", status.enabled ? "Yes" : "No"); - ts_console_printf(" Running: %s\n", status.is_running ? "Yes" : "No"); + ts_console_printf(" Running: %s\n", status.duty_valid ? (status.is_running ? "Yes" : "No") : "Unknown"); ts_console_printf(" Mode: %s\n", mode_to_str(status.mode)); - ts_console_printf(" Duty: %d%% (target: %d%%)\n", - status.duty_percent, status.target_duty); + if (status.duty_valid) { + ts_console_printf(" Duty: %d%% (target: %d%%)\n", status.duty_percent, status.target_duty); + } else { + ts_console_printf(" Duty: Unknown (target: %d%%)\n", status.target_duty); + } ts_console_printf(" RPM: %d\n", status.rpm); ts_console_printf(" Temperature: %.1f°C (stable: %.1f°C)\n", status.temp / 10.0f, status.last_stable_temp / 10.0f); @@ -137,11 +140,13 @@ static int do_fan_status(int fan_id, bool json) for (int i = 0; i < TS_FAN_MAX; i++) { if (ts_fan_get_status(i, &status) == ESP_OK) { - ts_console_printf("%-4d %-7s %-7s %5d%% %6d %5.1f° %s\n", + char duty[8] = "--"; + if (status.duty_valid) snprintf(duty, sizeof(duty), "%u%%", status.duty_percent); + ts_console_printf("%-4d %-7s %-7s %6s %6d %5.1f° %s\n", i, status.enabled ? "Yes" : "No", - status.is_running ? "Yes" : "No", - status.duty_percent, + status.duty_valid ? (status.is_running ? "Yes" : "No") : "Unknown", + duty, status.rpm, status.temp / 10.0f, mode_to_str(status.mode)); diff --git a/components/ts_console/commands/ts_cmd_ssh.c b/components/ts_console/commands/ts_cmd_ssh.c index 0a0b5a10..eea32c22 100644 --- a/components/ts_console/commands/ts_cmd_ssh.c +++ b/components/ts_console/commands/ts_cmd_ssh.c @@ -21,6 +21,7 @@ #include "ts_log.h" #include "ts_core.h" /* TS_MALLOC_PSRAM, TS_STRDUP_PSRAM */ #include "ts_ssh_client.h" +#include "ts_ssh_probe.h" #include "ts_ssh_shell.h" #include "ts_port_forward.h" #include "ts_crypto.h" @@ -895,7 +896,8 @@ static int do_ssh_copy_id(const char *host, int port, const char *user, ts_console_printf("[3/4] Deploying public key... "); /* 命令:创建 .ssh 目录并追加公钥到 authorized_keys */ - char *deploy_cmd = TS_MALLOC_PSRAM(strlen(pubkey_data) + 512); + size_t deploy_capacity = strlen(pubkey_data) * 4 + 256; + char *deploy_cmd = TS_MALLOC_PSRAM(deploy_capacity); if (!deploy_cmd) { ts_console_printf("FAILED (out of memory)\n"); ts_ssh_disconnect(session); @@ -904,12 +906,14 @@ static int do_ssh_copy_id(const char *host, int port, const char *user, return 1; } - snprintf(deploy_cmd, strlen(pubkey_data) + 512, - "mkdir -p ~/.ssh && chmod 700 ~/.ssh && " - "echo '%s' >> ~/.ssh/authorized_keys && " - "chmod 600 ~/.ssh/authorized_keys && " - "echo 'Key deployed successfully'", - pubkey_data); + if (!ts_ssh_copyid_command(pubkey_data, deploy_cmd, deploy_capacity)) { + free(deploy_cmd); + ts_ssh_disconnect(session); + ts_ssh_session_destroy(session); + free(pubkey_data); + ts_console_printf("FAILED (invalid public key deployment command)\n"); + return 1; + } ts_ssh_exec_result_t result; ret = ts_ssh_exec(session, deploy_cmd, &result); diff --git a/components/ts_drivers/include/ts_fan.h b/components/ts_drivers/include/ts_fan.h index 1bec82c2..c5ec15f4 100644 --- a/components/ts_drivers/include/ts_fan.h +++ b/components/ts_drivers/include/ts_fan.h @@ -81,12 +81,13 @@ typedef struct { /** Fan status */ typedef struct { ts_fan_mode_t mode; /**< 当前模式 */ + bool duty_valid; /**< 已确认成功下发过 PWM */ uint8_t duty_percent; /**< 当前占空比 */ uint8_t target_duty; /**< 目标占空比(曲线计算) */ uint16_t rpm; /**< 当前转速 */ int16_t temp; /**< 当前温度源 0.1°C */ int16_t last_stable_temp; /**< 上次稳定温度 0.1°C */ - bool is_running; /**< 是否运行中 */ + bool is_running; /**< 已确认 PWM 输出非零;不表示机械转速测量 */ bool enabled; /**< 是否启用 */ bool fault; /**< 故障标志 */ int16_t control_temp; /**< AUTO 控制温度 0.1°C */ diff --git a/components/ts_drivers/include/ts_temp_source.h b/components/ts_drivers/include/ts_temp_source.h index fce3e927..c72e05f4 100644 --- a/components/ts_drivers/include/ts_temp_source.h +++ b/components/ts_drivers/include/ts_temp_source.h @@ -78,6 +78,7 @@ typedef struct { int16_t value; /**< 温度值(0.1°C 单位)*/ ts_temp_source_type_t source; /**< 数据来源 */ int64_t timestamp_ms; /**< 更新时间戳 */ + uint32_t identity_revision; /**< 源/绑定配置身份;普通温度更新不改变 */ bool valid; /**< 数据有效性 */ int16_t guard_value; /**< 保护温度(0.1°C 单位),绑定变量最高 fresh 温度 */ bool guard_valid; /**< 保护温度是否有效 */ diff --git a/components/ts_drivers/src/ts_fan.c b/components/ts_drivers/src/ts_fan.c index 64c954f0..4ec75d41 100644 --- a/components/ts_drivers/src/ts_fan.c +++ b/components/ts_drivers/src/ts_fan.c @@ -59,7 +59,8 @@ typedef struct { ts_pwm_handle_t pwm; ts_gpio_handle_t tach_gpio; ts_fan_mode_t mode; - uint8_t current_duty; + uint8_t current_duty; /* Last successful logical PWM output. */ + bool duty_valid; uint8_t target_duty; uint16_t rpm; int16_t temperature; @@ -78,6 +79,10 @@ typedef struct { bool guard_active; bool temp_stale; int64_t guard_release_since_ms; + int64_t guard_last_observation_ms; + int64_t guard_last_report_ms; + ts_temp_source_type_t guard_last_source; + uint32_t guard_last_identity; bool response_observing; int64_t response_until_ms; float response_slope_before; @@ -254,6 +259,8 @@ static void reset_adaptive_auto_state(fan_instance_t *fan) fan->guard_active = false; fan->temp_stale = false; fan->guard_release_since_ms = 0; + fan->guard_last_observation_ms = 0; + fan->guard_last_report_ms = 0; fan->response_observing = false; fan->response_until_ms = 0; fan->response_slope_before = 0.0f; @@ -358,15 +365,22 @@ static uint8_t apply_auto_rate_limit(fan_instance_t *fan, uint8_t target, fan->auto_fall_credit = 0.0f; target = current; } else { - fan->auto_fall_credit += FAN_AUTO_FALL_RATE_PER_SEC * dt_sec; - uint8_t max_step = (uint8_t)fan->auto_fall_credit; + /* 已不需要的额外风量按温度趋势窗口平滑退出,避免峰值留下数分钟尾巴。 + * 接近当前需求时仍使用原来的慢速退档;升档和退档许可保持不变。 */ + float excess_rate = (current - target) / (FAN_AUTO_SLOPE_WINDOW_MS / 1000.0f); + float fall_rate = excess_rate > FAN_AUTO_FALL_RATE_PER_SEC + ? excess_rate : FAN_AUTO_FALL_RATE_PER_SEC; + fan->auto_fall_credit += fall_rate * dt_sec; + uint8_t desired_step = current - target; + uint8_t max_step = fan->auto_fall_credit >= desired_step + ? desired_step : (uint8_t)fan->auto_fall_credit; if (max_step == 0) { target = current; } else { - uint8_t desired_step = current - target; - uint8_t actual_step = desired_step < max_step ? desired_step : max_step; - target = current - actual_step; - fan->auto_fall_credit -= actual_step; + target = current - max_step; + /* Permission belongs to this outstanding demand, not a later one. */ + fan->auto_fall_credit = max_step == desired_step + ? 0.0f : fan->auto_fall_credit - max_step; } } } else { @@ -400,6 +414,28 @@ static void update_response_learning(fan_instance_t *fan, bool slope_valid, FAN_AUTO_GAIN_MAX); } +static void update_guard_observation(fan_instance_t *fan, const ts_temp_data_t *data, + int64_t now_ms) +{ + /* Valid cached readings count; a missed evaluation or changed source does not. + * Two timer periods allow normal scheduling jitter, capped by input validity. */ + int64_t max_gap_ms = 2LL * CONFIG_TS_DRIVERS_FAN_TEMP_UPDATE_MS; + if (max_gap_ms > TS_TEMP_DATA_TIMEOUT_MS) max_gap_ms = TS_TEMP_DATA_TIMEOUT_MS; + bool broken = fan->guard_last_observation_ms == 0 || + now_ms <= fan->guard_last_observation_ms || + now_ms - fan->guard_last_observation_ms > max_gap_ms || + data->source != fan->guard_last_source || + data->identity_revision != fan->guard_last_identity || + data->timestamp_ms < fan->guard_last_report_ms || + data->timestamp_ms - fan->guard_last_report_ms > TS_TEMP_DATA_TIMEOUT_MS || + data->timestamp_ms <= 0 || data->timestamp_ms > now_ms; + if (broken) fan->guard_release_since_ms = 0; + fan->guard_last_observation_ms = now_ms; + fan->guard_last_report_ms = data->timestamp_ms; + fan->guard_last_source = data->source; + fan->guard_last_identity = data->identity_revision; +} + static esp_err_t apply_adaptive_auto(fan_instance_t *fan, const ts_temp_data_t *temp_data) { int64_t now_ms = esp_timer_get_time() / 1000; @@ -415,6 +451,7 @@ static esp_err_t apply_adaptive_auto(fan_instance_t *fan, const ts_temp_data_t * fan->auto_fall_credit = 0.0f; fan->guard_active = false; fan->guard_release_since_ms = 0; + fan->guard_last_observation_ms = 0; fan->target_duty = FAN_AUTO_STALE_DUTY; fan->predicted_temperature = fan->control_temperature; fan->last_auto_update_ms = now_ms; @@ -427,6 +464,7 @@ static esp_err_t apply_adaptive_auto(fan_instance_t *fan, const ts_temp_data_t * fan->guard_temperature = guard_temp; record_auto_temp_sample(fan, control_temp, now_ms); + update_guard_observation(fan, temp_data, now_ms); if (guard_temp >= FAN_AUTO_GUARD_TEMP) { fan->guard_active = true; fan->guard_release_since_ms = 0; @@ -602,6 +640,8 @@ static esp_err_t update_pwm(fan_instance_t *fan, uint8_t duty) esp_err_t ret = ts_pwm_set_duty(fan->pwm, (float)actual); if (ret == ESP_OK) { fan->current_duty = duty; + fan->duty_valid = true; + fan->fault = false; } else { fan->fault = true; } @@ -667,7 +707,7 @@ static void fan_update_callback(void *arg) switch (fan->mode) { case TS_FAN_MODE_MANUAL: // 手动模式:保持当前设定 - update_pwm(fan, fan->current_duty); + update_pwm(fan, fan->target_duty); break; case TS_FAN_MODE_AUTO: @@ -828,6 +868,7 @@ esp_err_t ts_fan_configure(ts_fan_id_t fan, const ts_fan_config_t *config) fan_instance_t *f = &s_fans[fan]; f->config = *config; + f->duty_valid = false; // 创建 PWM 句柄 f->pwm = ts_pwm_create_raw(config->gpio_pwm, "fan"); @@ -874,14 +915,16 @@ esp_err_t ts_fan_configure(ts_fan_id_t fan, const ts_fan_config_t *config) f->initialized = true; f->enabled = true; f->mode = TS_FAN_MODE_MANUAL; - f->current_duty = config->min_duty; + f->current_duty = 0; f->fault = false; reset_adaptive_auto_state(f); + f->target_duty = config->min_duty; + ret = update_pwm(f, f->target_duty); TS_LOGI(TAG, "Fan %d configured: PWM=GPIO%d, TACH=%d, curve=%d points", fan, config->gpio_pwm, config->gpio_tach, config->curve_points); - return ESP_OK; + return ret; } /*===========================================================================*/ @@ -897,7 +940,11 @@ esp_err_t ts_fan_set_mode(ts_fan_id_t fan, ts_fan_mode_t mode) s_fans[fan].mode = mode; esp_err_t ret = ESP_OK; + if (mode == TS_FAN_MODE_MANUAL && old_mode != mode) { + s_fans[fan].target_duty = s_fans[fan].current_duty; + } if (mode == TS_FAN_MODE_OFF) { + s_fans[fan].target_duty = 0; ret = update_pwm(&s_fans[fan], 0); } @@ -943,8 +990,8 @@ esp_err_t ts_fan_set_duty(ts_fan_id_t fan, uint8_t duty_percent) if (duty_percent > 100) duty_percent = 100; s_fans[fan].mode = TS_FAN_MODE_MANUAL; - s_fans[fan].current_duty = duty_percent; reset_adaptive_auto_state(&s_fans[fan]); + s_fans[fan].target_duty = duty_percent; return update_pwm(&s_fans[fan], duty_percent); } @@ -957,7 +1004,7 @@ esp_err_t ts_fan_enable(ts_fan_id_t fan, bool enable) s_fans[fan].enabled = enable; if (!enable) { - update_pwm(&s_fans[fan], 0); + return update_pwm(&s_fans[fan], 0); } TS_LOGI(TAG, "Fan %d %s", fan, enable ? "enabled" : "disabled"); @@ -1062,24 +1109,16 @@ esp_err_t ts_fan_get_status(ts_fan_id_t fan, ts_fan_status_t *status) if (!s_fans[fan].initialized) return ESP_ERR_INVALID_STATE; fan_instance_t *f = &s_fans[fan]; - - /* 控制状态只由 fan_update_callback() 推进,状态查询不触发温度重算或调速 */ - if (f->mode == TS_FAN_MODE_CURVE) { - /* CURVE 的 target_duty 由定时器回调维护 */ - } else if (f->mode == TS_FAN_MODE_AUTO) { - /* 自适应状态只能在 fan_update_callback() 推进,这里不触发学习或调速 */ - } else { - /* MANUAL/OFF 模式:目标转速等于当前转速 */ - f->target_duty = f->current_duty; - } - + + /* Status reads never mutate the requested setting or the last output. */ status->mode = f->mode; status->duty_percent = f->current_duty; + status->duty_valid = f->duty_valid; status->target_duty = f->target_duty; status->rpm = f->rpm; status->temp = f->temperature; status->last_stable_temp = f->last_stable_temp; - status->is_running = f->current_duty > 0 && f->enabled; + status->is_running = f->duty_valid && f->current_duty > 0; status->enabled = f->enabled; status->fault = f->fault; status->control_temp = f->control_temperature; @@ -1116,16 +1155,19 @@ esp_err_t ts_fan_emergency_full(void) { TS_LOGW(TAG, "Emergency: All fans to 100%%"); + esp_err_t result = ESP_OK; for (int i = 0; i < TS_FAN_MAX; i++) { if (s_fans[i].initialized) { s_fans[i].mode = TS_FAN_MODE_MANUAL; s_fans[i].enabled = true; - s_fans[i].current_duty = 100; - update_pwm(&s_fans[i], 100); + reset_adaptive_auto_state(&s_fans[i]); + s_fans[i].target_duty = 100; + esp_err_t ret = update_pwm(&s_fans[i], 100); + if (ret != ESP_OK && result == ESP_OK) result = ret; } } - return ESP_OK; + return result; } /*===========================================================================*/ @@ -1167,7 +1209,8 @@ esp_err_t ts_fan_save_config(void) fan_nvs_config_t cfg = { .version = FAN_CONFIG_VERSION, .mode = s_fans[i].mode, - .duty = s_fans[i].current_duty, + .duty = s_fans[i].mode == TS_FAN_MODE_MANUAL + ? s_fans[i].target_duty : s_fans[i].current_duty, .enabled = s_fans[i].enabled, .curve_points = s_fans[i].config.curve_points, .hysteresis = s_fans[i].config.hysteresis, @@ -1211,7 +1254,8 @@ esp_err_t ts_fan_save_full_config(ts_fan_id_t fan) fan_nvs_config_t cfg = { .version = FAN_CONFIG_VERSION, .mode = s_fans[fan].mode, - .duty = s_fans[fan].current_duty, + .duty = s_fans[fan].mode == TS_FAN_MODE_MANUAL + ? s_fans[fan].target_duty : s_fans[fan].current_duty, .enabled = s_fans[fan].enabled, .curve_points = s_fans[fan].config.curve_points, .hysteresis = s_fans[fan].config.hysteresis, @@ -1241,6 +1285,7 @@ esp_err_t ts_fan_load_config(void) return ESP_ERR_NOT_FOUND; } + esp_err_t result = ESP_OK; for (int i = 0; i < TS_FAN_MAX; i++) { if (!s_fans[i].initialized) continue; @@ -1262,7 +1307,8 @@ esp_err_t ts_fan_load_config(void) // 恢复配置 s_fans[i].mode = cfg.mode; - s_fans[i].current_duty = cfg.duty; + reset_adaptive_auto_state(&s_fans[i]); + s_fans[i].target_duty = cfg.mode == TS_FAN_MODE_OFF ? 0 : cfg.duty; s_fans[i].enabled = cfg.enabled; s_fans[i].config.curve_points = cfg.curve_points; s_fans[i].config.hysteresis = cfg.hysteresis; @@ -1274,17 +1320,22 @@ esp_err_t ts_fan_load_config(void) cfg.curve_points * sizeof(ts_fan_curve_point_t)); if (s_fans[i].mode == TS_FAN_MODE_AUTO && s_fans[i].pwm) { - apply_auto_immediate(&s_fans[i]); + ret = apply_auto_immediate(&s_fans[i]); } else if (s_fans[i].mode == TS_FAN_MODE_MANUAL && s_fans[i].pwm) { - update_pwm(&s_fans[i], cfg.duty); + ret = update_pwm(&s_fans[i], cfg.duty); + } else if (s_fans[i].mode == TS_FAN_MODE_OFF && s_fans[i].pwm) { + ret = update_pwm(&s_fans[i], 0); + } else { + ret = ESP_OK; } + if (ret != ESP_OK && result == ESP_OK) result = ret; TS_LOGI(TAG, "Restored fan %d: mode=%d, duty=%d%%, curve=%d pts", i, cfg.mode, cfg.duty, cfg.curve_points); } nvs_close(nvs); - return ESP_OK; + return result; } /*===========================================================================*/ /* Temperature Source Integration */ diff --git a/components/ts_drivers/src/ts_temp_source.c b/components/ts_drivers/src/ts_temp_source.c index 7a436ff4..ff27ea1c 100644 --- a/components/ts_drivers/src/ts_temp_source.c +++ b/components/ts_drivers/src/ts_temp_source.c @@ -51,6 +51,7 @@ typedef struct { bool manual_mode; int16_t manual_temp; int16_t current_temp; + uint32_t identity_revision; bool variable_valid; int64_t variable_last_update_ms; int16_t variable_guard_temp; @@ -418,6 +419,7 @@ static void evaluate_active_source_with_lock_mode(bool try_variable_lock) ts_temp_source_type_t prev_source = s_state.active_source; s_state.current_temp = best_temp; + if (best_source != prev_source) s_state.identity_revision++; s_state.active_source = best_source; publish_temp_event(best_temp, best_source, prev_temp, prev_source); @@ -649,6 +651,7 @@ static void fill_effective_data_locked(ts_temp_data_t *data) data->value = temp; data->source = source; + data->identity_revision = s_state.identity_revision; data->timestamp_ms = (source == TS_TEMP_SOURCE_VARIABLE) ? s_state.variable_last_update_ms : s_state.providers[source].last_update_ms; @@ -709,6 +712,7 @@ esp_err_t ts_temp_get_by_source(ts_temp_source_type_t type, ts_temp_data_t *data store_variable_snapshot(&snapshot); data->value = valid ? snapshot.temp : TS_TEMP_DEFAULT_VALUE; data->source = TS_TEMP_SOURCE_VARIABLE; + data->identity_revision = s_state.identity_revision; data->timestamp_ms = snapshot.timestamp_ms; data->valid = valid; data->guard_value = snapshot.guard_temp; @@ -730,6 +734,7 @@ esp_err_t ts_temp_get_by_source(ts_temp_source_type_t type, ts_temp_data_t *data data->value = p->value; data->source = p->type; + data->identity_revision = s_state.identity_revision; data->timestamp_ms = p->last_update_ms; data->valid = (type != TS_TEMP_SOURCE_DEFAULT) && is_provider_valid(type, get_current_ms(), false); @@ -894,6 +899,7 @@ esp_err_t ts_temp_set_preferred_source(ts_temp_source_type_t type) ts_temp_source_type_t old_preferred = s_state.preferred_source; s_state.preferred_source = type; + if (type != old_preferred) s_state.identity_revision++; // 切换到非手动源时,自动禁用手动模式 if (s_state.manual_mode) { @@ -963,6 +969,7 @@ static esp_err_t load_preferred_source_from_nvs(void) return ESP_OK; } + s_state.identity_revision++; uint8_t preferred = 0; ret = nvs_get_u8(handle, NVS_KEY_PREFERRED, &preferred); @@ -1053,6 +1060,7 @@ static esp_err_t load_temp_config_from_file(const char *filepath) return ESP_ERR_INVALID_ARG; } + s_state.identity_revision++; /* Parse preferred_source */ cJSON *pref = cJSON_GetObjectItem(root, "preferred_source"); if (pref && cJSON_IsString(pref)) { @@ -1360,6 +1368,12 @@ esp_err_t ts_temp_bind_variables(const ts_temp_bound_var_t *vars, uint8_t count) xSemaphoreTake(s_state.mutex, portMAX_DELAY); + bool changed = s_state.bound_var_count != count; + for (uint8_t i = 0; !changed && i < count; i++) { + changed = strcmp(s_state.bound_vars[i].name, validated_vars[i].name) != 0 || + s_state.bound_vars[i].weight != validated_vars[i].weight; + } + if (changed) s_state.identity_revision++; s_state.bound_var_count = count; memcpy(s_state.bound_vars, validated_vars, count * sizeof(ts_temp_bound_var_t)); sync_bound_variable_compat(); @@ -1425,6 +1439,7 @@ esp_err_t ts_temp_unbind_variable(void) xSemaphoreTake(s_state.mutex, portMAX_DELAY); bool was_bound = (s_state.bound_var_count > 0); + if (was_bound) s_state.identity_revision++; s_state.bound_var_count = 0; memset(s_state.bound_vars, 0, sizeof(s_state.bound_vars)); s_state.bound_variable[0] = '\0'; diff --git a/components/ts_security/include/ts_keystore.h b/components/ts_security/include/ts_keystore.h index 7b439132..3a433010 100644 --- a/components/ts_security/include/ts_keystore.h +++ b/components/ts_security/include/ts_keystore.h @@ -117,6 +117,19 @@ typedef struct { bool hidden; /**< Hide key ID from unauthenticated users (default: false) */ } ts_keystore_gen_opts_t; +/** Per-call generation outcome. Storage success is independent of HTTP delivery. */ +typedef struct { + const char *failed_stage; /**< Static stage name, NULL after storage success */ + esp_err_t cleanup_error; + bool cleanup_complete; /**< Only this new ID's cleanup */ + bool stored; +} ts_keystore_generate_result_t; + +esp_err_t ts_keystore_generate_key_with_result(const char *id, + ts_keystore_key_type_t type, + const ts_keystore_gen_opts_t *opts, + ts_keystore_generate_result_t *result); + /** * @brief Default generation options (not exportable, not hidden) */ diff --git a/components/ts_security/include/ts_ssh_client.h b/components/ts_security/include/ts_ssh_client.h index c26885fd..0f4c6c37 100644 --- a/components/ts_security/include/ts_ssh_client.h +++ b/components/ts_security/include/ts_ssh_client.h @@ -173,6 +173,17 @@ esp_err_t ts_ssh_exec_stream(ts_ssh_session_t session, const char *command, ts_ssh_output_cb_t callback, void *user_data, int *exit_code); +/** + * @brief Execute a command with stdin bytes and streaming output + * + * Sends input without including it in command text, then closes stdin. + * The caller owns the input buffer and must clear sensitive bytes after use. + */ +esp_err_t ts_ssh_exec_stream_input(ts_ssh_session_t session, const char *command, + const char *input, size_t input_len, + ts_ssh_output_cb_t callback, void *user_data, + int *exit_code); + /** * @brief Abort ongoing SSH operation * diff --git a/components/ts_security/include/ts_ssh_probe.h b/components/ts_security/include/ts_ssh_probe.h index 924b4fab..878664f7 100644 --- a/components/ts_security/include/ts_ssh_probe.h +++ b/components/ts_security/include/ts_ssh_probe.h @@ -7,6 +7,8 @@ extern "C" { #endif /* Shell words are always single-quoted; inputs must be NUL terminated. */ bool ts_ssh_shell_quote(const char *input, char *out, size_t capacity); +/* Construct public-key deployment using one literal shell word. */ +bool ts_ssh_copyid_command(const char *public_key, char *out, size_t capacity); bool ts_ssh_log_probe_command(const char *path, const char *ready, const char *fail, char *out, size_t capacity); /* Exact protocol token, one optional LF/CRLF; no substring matching. */ diff --git a/components/ts_security/include/ts_ssh_service.h b/components/ts_security/include/ts_ssh_service.h index 0783c9d1..d5e6eb5b 100644 --- a/components/ts_security/include/ts_ssh_service.h +++ b/components/ts_security/include/ts_ssh_service.h @@ -8,15 +8,31 @@ extern "C" { #endif +typedef enum { TS_SERVICE_START = 1, TS_SERVICE_VERIFY, TS_SERVICE_STOP } ts_service_operation_t; + typedef struct { char state[16], last_known[16], source[16]; uint32_t generation; int64_t confirmed_ms; bool busy; + uint32_t operation_id; + char operation_phase[12]; + char operation_kind[8]; + esp_err_t operation_error; } ts_ssh_service_status_t; esp_err_t ts_ssh_service_init(void); void ts_ssh_service_safe_name(const ts_ssh_command_config_t *cmd, char out[32]); /* reserve -> probe -> execute -> finish; network calls never hold registry lock */ +/* Pending controls own a reservation, separate from configuration pins. */ +esp_err_t ts_ssh_service_reserve_operation(const char *id, uint32_t registration, + ts_service_operation_t kind, uint32_t *operation_id); +esp_err_t ts_ssh_service_operation_begin(const char *id, uint32_t operation_id); +esp_err_t ts_ssh_service_execute_control(const char *id, uint32_t operation_id, + ts_service_operation_t kind, ts_ssh_service_status_t *out); +/* Returns true only if this credential actually entered execution. */ +bool ts_ssh_service_complete_operation(const char *id, uint32_t operation_id, esp_err_t error); +esp_err_t ts_ssh_service_begin_reserved(const ts_ssh_command_config_t *cmd, ts_ssh_session_t session, + uint32_t operation_id, uint32_t *generation); esp_err_t ts_ssh_service_begin(const ts_ssh_command_config_t *cmd, ts_ssh_session_t session, uint32_t *generation); bool ts_ssh_service_finish(const char *id, uint32_t generation, const char *receipt, ts_ssh_session_t session); diff --git a/components/ts_security/src/ts_keystore.c b/components/ts_security/src/ts_keystore.c index 7b342903..9cf73132 100644 --- a/components/ts_security/src/ts_keystore.c +++ b/components/ts_security/src/ts_keystore.c @@ -21,6 +21,8 @@ #include "esp_log.h" #include "esp_system.h" #include "cJSON.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include #include #include @@ -39,6 +41,8 @@ static const char *TAG = "ts_keystore"; static struct { bool initialized; nvs_handle_t nvs_handle; + SemaphoreHandle_t write_lock; + StaticSemaphore_t write_lock_storage; } s_keystore = { .initialized = false, .nvs_handle = 0, @@ -89,15 +93,15 @@ static int parse_index(const char *index_str, char ids[][TS_KEYSTORE_ID_MAX_LEN] int count = 0; char *copy = TS_STRDUP_PSRAM(index_str); if (!copy) return 0; - - char *token = strtok(copy, ","); + char *saveptr = NULL; + char *token = strtok_r(copy, ",", &saveptr); while (token && count < max_ids) { strncpy(ids[count], token, TS_KEYSTORE_ID_MAX_LEN - 1); ids[count][TS_KEYSTORE_ID_MAX_LEN - 1] = '\0'; count++; - token = strtok(NULL, ","); + token = strtok_r(NULL, ",", &saveptr); } - + free(copy); return count; } @@ -105,30 +109,49 @@ static int parse_index(const char *index_str, char ids[][TS_KEYSTORE_ID_MAX_LEN] /** * @brief 添加 ID 到索引 */ +/* Writers hold write_lock across the entire read/modify/write operation. */ +static esp_err_t read_index(char *index, size_t size, bool *present) +{ + index[0] = '\0'; + if (present) *present = false; + esp_err_t ret = nvs_get_str(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY, index, &size); + if (present && ret == ESP_OK) *present = true; + return ret == ESP_ERR_NVS_NOT_FOUND ? ESP_OK : ret; +} + +static bool index_contains(const char *index, const char *id) +{ + size_t id_len = strlen(id); + for (const char *p = index; *p;) { + const char *end = strchr(p, ','); + size_t len = end ? (size_t)(end - p) : strlen(p); + if (len == id_len && memcmp(p, id, len) == 0) return true; + if (!end) break; + p = end + 1; + } + return false; +} + +static esp_err_t append_index(char *index, size_t size, const char *id) +{ + if (index_contains(index, id)) return ESP_OK; + size_t used = strlen(index), count = used ? 1 : 0; + for (const char *p = index; *p; ++p) if (*p == ',') ++count; + if (count >= TS_KEYSTORE_MAX_KEYS) return ESP_ERR_INVALID_SIZE; + size_t needed = used + (used ? 1 : 0) + strlen(id) + 1; + if (needed > size) return ESP_ERR_INVALID_SIZE; + if (used) index[used++] = ','; + strcpy(index + used, id); + return ESP_OK; +} + static esp_err_t add_to_index(const char *id) { - char index_str[MAX_INDEX_LEN] = {0}; - size_t len = sizeof(index_str); - - /* 读取现有索引 */ - esp_err_t ret = nvs_get_str(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY, index_str, &len); - if (ret != ESP_OK && ret != ESP_ERR_NVS_NOT_FOUND) { - return ret; - } - - /* 检查是否已存在 */ - if (strstr(index_str, id) != NULL) { - return ESP_OK; /* 已存在 */ - } - - /* 追加新 ID */ - if (strlen(index_str) > 0) { - strncat(index_str, ",", sizeof(index_str) - strlen(index_str) - 1); - } - strncat(index_str, id, sizeof(index_str) - strlen(index_str) - 1); - - /* 写回 */ - return nvs_set_str(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY, index_str); + char index[MAX_INDEX_LEN]; + esp_err_t ret = read_index(index, sizeof(index), NULL); + if (ret != ESP_OK) return ret; + ret = append_index(index, sizeof(index), id); + return ret == ESP_OK ? nvs_set_str(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY, index) : ret; } /** @@ -169,15 +192,18 @@ static esp_err_t store_metadata(const char *id, const ts_keystore_key_info_t *in cJSON *json = cJSON_CreateObject(); if (!json) return ESP_ERR_NO_MEM; - cJSON_AddStringToObject(json, "type", ts_keystore_type_to_string(info->type)); - cJSON_AddStringToObject(json, "comment", info->comment); - cJSON_AddStringToObject(json, "alias", info->alias); - cJSON_AddNumberToObject(json, "created_at", info->created_at); - cJSON_AddNumberToObject(json, "last_used", info->last_used); - cJSON_AddBoolToObject(json, "has_pubkey", info->has_public_key); - cJSON_AddBoolToObject(json, "exportable", info->exportable); - cJSON_AddBoolToObject(json, "hidden", info->hidden); - + if (!cJSON_AddStringToObject(json, "type", ts_keystore_type_to_string(info->type)) || + !cJSON_AddStringToObject(json, "comment", info->comment) || + !cJSON_AddStringToObject(json, "alias", info->alias) || + !cJSON_AddNumberToObject(json, "created_at", info->created_at) || + !cJSON_AddNumberToObject(json, "last_used", info->last_used) || + !cJSON_AddBoolToObject(json, "has_pubkey", info->has_public_key) || + !cJSON_AddBoolToObject(json, "exportable", info->exportable) || + !cJSON_AddBoolToObject(json, "hidden", info->hidden)) { + cJSON_Delete(json); + return ESP_ERR_NO_MEM; + } + char *str = cJSON_PrintUnformatted(json); cJSON_Delete(json); @@ -260,10 +286,11 @@ static esp_err_t load_metadata(const char *id, ts_keystore_key_info_t *info) /* ========== 公开 API ========== */ /* 内部函数:带选项的密钥存储 */ -static esp_err_t ts_keystore_store_key_ex(const char *id, +static esp_err_t store_key_locked(const char *id, const ts_keystore_keypair_t *keypair, ts_keystore_key_type_t type, - const ts_keystore_gen_opts_t *opts) + const ts_keystore_gen_opts_t *opts, + ts_keystore_generate_result_t *detail) { if (!s_keystore.initialized) { return ESP_ERR_INVALID_STATE; @@ -290,6 +317,7 @@ static esp_err_t ts_keystore_store_key_ex(const char *id, char nvs_key[TS_KEYSTORE_ID_MAX_LEN + 8]; esp_err_t ret; + if (detail) detail->failed_stage = "private_write"; /* 存储私钥 */ make_nvs_key(nvs_key, sizeof(nvs_key), id, "_priv"); ret = nvs_set_blob(s_keystore.nvs_handle, nvs_key, @@ -302,12 +330,13 @@ static esp_err_t ts_keystore_store_key_ex(const char *id, /* 存储公钥(如果有) */ bool has_pubkey = false; if (keypair->public_key && keypair->public_key_len > 0) { + if (detail) detail->failed_stage = "public_write"; make_nvs_key(nvs_key, sizeof(nvs_key), id, "_pub"); ret = nvs_set_blob(s_keystore.nvs_handle, nvs_key, keypair->public_key, keypair->public_key_len); if (ret != ESP_OK) { ESP_LOGW(TAG, "Failed to store public key: %s", esp_err_to_name(ret)); - /* 继续,公钥是可选的 */ + if (detail) return ret; /* Generated keys require their public key. */ } else { has_pubkey = true; } @@ -330,60 +359,126 @@ static esp_err_t ts_keystore_store_key_ex(const char *id, strncpy(info.alias, alias, sizeof(info.alias) - 1); } + if (detail) detail->failed_stage = "metadata_write"; ret = store_metadata(id, &info); if (ret != ESP_OK) { ESP_LOGW(TAG, "Failed to store metadata: %s", esp_err_to_name(ret)); + return ret; } /* 添加到索引 */ + if (detail) detail->failed_stage = "index_write"; ret = add_to_index(id); if (ret != ESP_OK) { ESP_LOGW(TAG, "Failed to update index: %s", esp_err_to_name(ret)); + return ret; } /* 提交更改 */ + if (detail) detail->failed_stage = "commit"; ret = nvs_commit(s_keystore.nvs_handle); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to commit NVS: %s", esp_err_to_name(ret)); return ret; } + if (detail) { detail->stored = true; detail->failed_stage = NULL; } ESP_LOGI(TAG, "Key '%s' stored successfully", id); return ESP_OK; } +static esp_err_t ts_keystore_store_key_ex(const char *id, + const ts_keystore_keypair_t *keypair, + ts_keystore_key_type_t type, + const ts_keystore_gen_opts_t *opts) +{ + if (!s_keystore.initialized) return ESP_ERR_INVALID_STATE; + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + esp_err_t ret = store_key_locked(id, keypair, type, opts, NULL); + xSemaphoreGive(s_keystore.write_lock); + return ret; +} + +/* New generation cannot replace an indexed key or an unlisted fragment. */ +static esp_err_t check_new_key(const char *id, char *index, bool *index_present, + ts_keystore_generate_result_t *detail) +{ + detail->failed_stage = "initialization"; + if (!s_keystore.initialized) return ESP_ERR_INVALID_STATE; + detail->failed_stage = "index_read"; + esp_err_t ret = read_index(index, MAX_INDEX_LEN, index_present); + if (ret != ESP_OK) return ret; + detail->failed_stage = "id_occupied"; + if (index_contains(index, id)) return ESP_ERR_INVALID_STATE; + const char *suffixes[] = {"_priv", "_pub", "_meta"}; + char key[TS_KEYSTORE_ID_MAX_LEN + 8]; + for (size_t i = 0; i < 3; ++i) { + make_nvs_key(key, sizeof(key), id, suffixes[i]); + size_t len = 0; + ret = i == 2 ? nvs_get_str(s_keystore.nvs_handle, key, NULL, &len) + : nvs_get_blob(s_keystore.nvs_handle, key, NULL, &len); + if (ret == ESP_OK) return ESP_ERR_INVALID_STATE; + if (ret != ESP_ERR_NVS_NOT_FOUND) { detail->failed_stage = "record_read"; return ret; } + } + detail->failed_stage = "index_capacity"; + char updated[MAX_INDEX_LEN]; + strcpy(updated, index); + return append_index(updated, sizeof(updated), id); +} + +static esp_err_t cleanup_new_key(const char *id, const char *old_index, + bool index_attempted, bool index_present) +{ + esp_err_t ret; + if (index_attempted) { + char current[MAX_INDEX_LEN]; + ret = read_index(current, sizeof(current), NULL); + if (ret != ESP_OK) return ret; + if (index_contains(current, id)) { + ret = index_present ? nvs_set_str(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY, old_index) + : nvs_erase_key(s_keystore.nvs_handle, KEYSTORE_INDEX_KEY); + if (ret != ESP_OK && ret != ESP_ERR_NVS_NOT_FOUND) return ret; /* Keep materials if index removal failed. */ + } + } + const char *suffixes[] = {"_meta", "_pub", "_priv"}; + esp_err_t first_error = ESP_OK; + char key[TS_KEYSTORE_ID_MAX_LEN + 8]; + for (size_t i = 0; i < 3; ++i) { + make_nvs_key(key, sizeof(key), id, suffixes[i]); + ret = nvs_erase_key(s_keystore.nvs_handle, key); + if (ret != ESP_OK && ret != ESP_ERR_NVS_NOT_FOUND && first_error == ESP_OK) first_error = ret; + } + ret = nvs_commit(s_keystore.nvs_handle); + return first_error != ESP_OK ? first_error : ret; +} + esp_err_t ts_keystore_init(void) { - if (s_keystore.initialized) { - return ESP_OK; + /* Static lock remains valid for writers waiting while the service stops. */ + if (!s_keystore.write_lock) { + s_keystore.write_lock = xSemaphoreCreateMutexStatic(&s_keystore.write_lock_storage); + if (!s_keystore.write_lock) return ESP_ERR_NO_MEM; } - - ESP_LOGI(TAG, "Initializing secure keystore..."); - - /* 打开 NVS 命名空间 */ - esp_err_t ret = nvs_open(KEYSTORE_NAMESPACE, NVS_READWRITE, &s_keystore.nvs_handle); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Failed to open NVS namespace: %s", esp_err_to_name(ret)); - return ret; + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + esp_err_t ret = ESP_OK; + if (!s_keystore.initialized) { + ret = nvs_open(KEYSTORE_NAMESPACE, NVS_READWRITE, &s_keystore.nvs_handle); + if (ret == ESP_OK) s_keystore.initialized = true; } - - s_keystore.initialized = true; - ESP_LOGI(TAG, "Keystore initialized"); - - return ESP_OK; + xSemaphoreGive(s_keystore.write_lock); + return ret; } esp_err_t ts_keystore_deinit(void) { - if (!s_keystore.initialized) { - return ESP_OK; + if (!s_keystore.write_lock) return ESP_OK; + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + if (s_keystore.initialized) { + nvs_close(s_keystore.nvs_handle); + s_keystore.nvs_handle = 0; + s_keystore.initialized = false; } - - nvs_close(s_keystore.nvs_handle); - s_keystore.nvs_handle = 0; - s_keystore.initialized = false; - - ESP_LOGI(TAG, "Keystore deinitialized"); + xSemaphoreGive(s_keystore.write_lock); return ESP_OK; } @@ -508,6 +603,11 @@ esp_err_t ts_keystore_delete_key(const char *id) return ESP_ERR_INVALID_ARG; } + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + if (!s_keystore.initialized) { + xSemaphoreGive(s_keystore.write_lock); + return ESP_ERR_INVALID_STATE; + } ESP_LOGI(TAG, "Deleting key '%s'", id); char nvs_key[TS_KEYSTORE_ID_MAX_LEN + 8]; @@ -529,6 +629,7 @@ esp_err_t ts_keystore_delete_key(const char *id) nvs_commit(s_keystore.nvs_handle); + xSemaphoreGive(s_keystore.write_lock); ESP_LOGI(TAG, "Key '%s' deleted", id); return ESP_OK; } @@ -666,14 +767,15 @@ esp_err_t ts_keystore_touch_key(const char *id) return ESP_ERR_INVALID_ARG; } + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); ts_keystore_key_info_t info; - esp_err_t ret = load_metadata(id, &info); - if (ret != ESP_OK) { - return ret; + esp_err_t ret = s_keystore.initialized ? load_metadata(id, &info) : ESP_ERR_INVALID_STATE; + if (ret == ESP_OK) { + info.last_used = (uint32_t)time(NULL); + ret = store_metadata(id, &info); } - - info.last_used = (uint32_t)time(NULL); - return store_metadata(id, &info); + xSemaphoreGive(s_keystore.write_lock); + return ret; } esp_err_t ts_keystore_import_from_file(const char *id, @@ -903,15 +1005,19 @@ esp_err_t ts_keystore_export_private_key_to_file(const char *id, const char *pat return ESP_OK; } -esp_err_t ts_keystore_generate_key_ex(const char *id, +esp_err_t ts_keystore_generate_key_with_result(const char *id, ts_keystore_key_type_t type, - const ts_keystore_gen_opts_t *opts) + const ts_keystore_gen_opts_t *opts, + ts_keystore_generate_result_t *detail) { + ts_keystore_generate_result_t local = {0}; + if (!detail) detail = &local; + *detail = (ts_keystore_generate_result_t){ .failed_stage = "validation", .cleanup_complete = true }; if (!s_keystore.initialized) { return ESP_ERR_INVALID_STATE; } - if (!id || type == TS_KEYSTORE_TYPE_UNKNOWN) { + if (!id || !*id || strlen(id) > NVS_KEY_NAME_MAX_SIZE - 1 - 5 || strchr(id, ',') || type == TS_KEYSTORE_TYPE_UNKNOWN) { return ESP_ERR_INVALID_ARG; } @@ -943,15 +1049,23 @@ esp_err_t ts_keystore_generate_key_ex(const char *id, return ESP_ERR_INVALID_ARG; } + char old_index[MAX_INDEX_LEN]; + bool index_present; + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + esp_err_t ret = check_new_key(id, old_index, &index_present, detail); + xSemaphoreGive(s_keystore.write_lock); + if (ret != ESP_OK) return ret; + detail->failed_stage = "generate"; /* 生成密钥对 */ ts_keypair_t keypair = NULL; - esp_err_t ret = ts_crypto_keypair_generate(crypto_type, &keypair); + ret = ts_crypto_keypair_generate(crypto_type, &keypair); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to generate keypair: %s", esp_err_to_name(ret)); return ret; } /* 导出私钥 PEM */ + detail->failed_stage = "private_export"; char *priv_key = TS_MALLOC_PSRAM(TS_KEYSTORE_PRIVKEY_MAX_LEN); if (!priv_key) { ts_crypto_keypair_free(keypair); @@ -959,27 +1073,25 @@ esp_err_t ts_keystore_generate_key_ex(const char *id, } size_t priv_len = TS_KEYSTORE_PRIVKEY_MAX_LEN; ret = ts_crypto_keypair_export_private(keypair, priv_key, &priv_len); - if (ret != ESP_OK) { + if (ret != ESP_OK || priv_len == 0) { ESP_LOGE(TAG, "Failed to export private key: %s", esp_err_to_name(ret)); secure_free_key(priv_key, TS_KEYSTORE_PRIVKEY_MAX_LEN); ts_crypto_keypair_free(keypair); - return ret; + return ret != ESP_OK ? ret : ESP_ERR_INVALID_RESPONSE; } /* 导出公钥 OpenSSH 格式 */ + detail->failed_stage = "public_export"; char *pub_key = TS_MALLOC_PSRAM(TS_KEYSTORE_PUBKEY_MAX_LEN); - size_t pub_len = 0; - if (pub_key) { - pub_len = TS_KEYSTORE_PUBKEY_MAX_LEN; - ret = ts_crypto_keypair_export_openssh(keypair, pub_key, &pub_len, opts->comment); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "Failed to export public key: %s", esp_err_to_name(ret)); - free(pub_key); - pub_key = NULL; - pub_len = 0; - } + size_t pub_len = TS_KEYSTORE_PUBKEY_MAX_LEN; + ret = pub_key ? ts_crypto_keypair_export_openssh(keypair, pub_key, &pub_len, opts->comment) : ESP_ERR_NO_MEM; + if (ret != ESP_OK || pub_len == 0) { + secure_free_key(priv_key, TS_KEYSTORE_PRIVKEY_MAX_LEN); + free(pub_key); + ts_crypto_keypair_free(keypair); + return ret != ESP_OK ? ret : ESP_ERR_INVALID_RESPONSE; } - + ts_crypto_keypair_free(keypair); /* 存储密钥 */ @@ -990,7 +1102,18 @@ esp_err_t ts_keystore_generate_key_ex(const char *id, .public_key_len = pub_len, }; - ret = ts_keystore_store_key_ex(id, &kp, type, opts); + xSemaphoreTake(s_keystore.write_lock, portMAX_DELAY); + ret = check_new_key(id, old_index, &index_present, detail); + if (ret == ESP_OK) { + ret = store_key_locked(id, &kp, type, opts, detail); + if (ret != ESP_OK) { + bool index_attempted = strcmp(detail->failed_stage, "index_write") == 0 || + strcmp(detail->failed_stage, "commit") == 0; + detail->cleanup_error = cleanup_new_key(id, old_index, index_attempted, index_present); + detail->cleanup_complete = detail->cleanup_error == ESP_OK; + } + } + xSemaphoreGive(s_keystore.write_lock); /* 安全清零私钥内存 */ secure_free_key(priv_key, priv_len); @@ -999,6 +1122,13 @@ esp_err_t ts_keystore_generate_key_ex(const char *id, return ret; } +esp_err_t ts_keystore_generate_key_ex(const char *id, + ts_keystore_key_type_t type, + const ts_keystore_gen_opts_t *opts) +{ + return ts_keystore_generate_key_with_result(id, type, opts, NULL); +} + esp_err_t ts_keystore_generate_key(const char *id, ts_keystore_key_type_t type, const char *comment) diff --git a/components/ts_security/src/ts_ssh_client.c b/components/ts_security/src/ts_ssh_client.c index 8a59d7ec..7b199c07 100644 --- a/components/ts_security/src/ts_ssh_client.c +++ b/components/ts_security/src/ts_ssh_client.c @@ -371,8 +371,9 @@ ts_ssh_state_t ts_ssh_get_state(ts_ssh_session_t session) { } static esp_err_t exec_command(ts_ssh_session_t s, const char *command, ts_ssh_output_cb_t callback, - void *context, ts_ssh_exec_result_t *result, int *exit_code) { - if (!s || !command || (!callback && !result)) + void *context, ts_ssh_exec_result_t *result, int *exit_code, + const char *input, size_t input_len) { + if (!s || !command || (!callback && !result) || (!input && input_len)) return ESP_ERR_INVALID_ARG; if (!ts_ssh_is_connected(s)) return ESP_ERR_INVALID_STATE; @@ -402,6 +403,30 @@ static esp_err_t exec_command(ts_ssh_session_t s, const char *command, ts_ssh_ou goto timeout; if (rc) goto failed; + if (input) { + size_t sent = 0; + while (sent < input_len) { + if (operation_expired(s)) + goto timeout; + ssize_t written = libssh2_channel_write(channel, input + sent, input_len - sent); + if (written == LIBSSH2_ERROR_EAGAIN || written == 0) { + if (wait_socket(s) < 0) + goto timeout; + } else if (written < 0) { + set_error(s, "Failed to send command input"); + goto failed; + } else { + sent += (size_t)written; + } + } + while ((rc = libssh2_channel_send_eof(channel)) == LIBSSH2_ERROR_EAGAIN) + if (wait_socket(s) < 0) + goto timeout; + if (rc) { + set_error(s, "Failed to close command input"); + goto failed; + } + } size_t capacity[2] = {0, 0}; size_t limit = s->config.max_output_bytes ? s->config.max_output_bytes : 65536; for (;;) { @@ -474,12 +499,18 @@ static esp_err_t exec_command(ts_ssh_session_t s, const char *command, ts_ssh_ou } esp_err_t ts_ssh_exec(ts_ssh_session_t s, const char *command, ts_ssh_exec_result_t *result) { - return exec_command(s, command, NULL, NULL, result, NULL); + return exec_command(s, command, NULL, NULL, result, NULL, NULL, 0); } esp_err_t ts_ssh_exec_stream(ts_ssh_session_t s, const char *command, ts_ssh_output_cb_t callback, void *context, int *exit_code) { - return exec_command(s, command, callback, context, NULL, exit_code); + return exec_command(s, command, callback, context, NULL, exit_code, NULL, 0); +} + +esp_err_t ts_ssh_exec_stream_input(ts_ssh_session_t s, const char *command, + const char *input, size_t input_len, + ts_ssh_output_cb_t callback, void *context, int *exit_code) { + return exec_command(s, command, callback, context, NULL, exit_code, input, input_len); } void ts_ssh_abort(ts_ssh_session_t session) { @@ -553,4 +584,4 @@ const char *ts_ssh_get_host(ts_ssh_session_t session) { /** * @brief 获取远程端口 */ -uint16_t ts_ssh_get_port(ts_ssh_session_t session) { return session ? session->config.port : 0; } \ No newline at end of file +uint16_t ts_ssh_get_port(ts_ssh_session_t session) { return session ? session->config.port : 0; } diff --git a/components/ts_security/src/ts_ssh_probe.c b/components/ts_security/src/ts_ssh_probe.c index 3960c4f4..8f871602 100644 --- a/components/ts_security/src/ts_ssh_probe.c +++ b/components/ts_security/src/ts_ssh_probe.c @@ -20,6 +20,22 @@ bool ts_ssh_shell_quote(const char *input, char *out, size_t cap) { return true; } +bool ts_ssh_copyid_command(const char *public_key, char *out, size_t cap) { + if (!public_key || !*public_key || !out) return false; + const char *prefix = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '%s' "; + const char *suffix = " >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && " + "printf '%s\\n' 'Key deployed successfully'"; + size_t n = strlen(prefix); + if (n >= cap) return false; + memcpy(out, prefix, n); + if (!ts_ssh_shell_quote(public_key, out + n, cap - n)) return false; + n += strlen(out + n); + size_t len = strlen(suffix); + if (len >= cap - n) return false; + memcpy(out + n, suffix, len + 1); + return true; +} + bool ts_ssh_log_probe_command(const char *path, const char *ready, const char *fail, char *out, size_t cap) { /* Build each quoted value directly in the bounded output, without scratch diff --git a/components/ts_security/src/ts_ssh_service.c b/components/ts_security/src/ts_ssh_service.c index 5d3dd2b9..7dd000dc 100644 --- a/components/ts_security/src/ts_ssh_service.c +++ b/components/ts_security/src/ts_ssh_service.c @@ -18,7 +18,7 @@ typedef struct { char id[32], host[64], host_id[32], name[32], variable[32]; char owner_rule[32]; char instance[96]; /* boot ID + PID + /proc start ticks */ - uint32_t registration, operation; + uint32_t registration, operation, pending_control; unsigned recovery_left; bool deleting, ready_evidence, recovery_worker; uint16_t port; @@ -31,7 +31,7 @@ static SemaphoreHandle_t mutex, binding_gate; static uint32_t serial; static uint32_t ticket(void) { return serial == UINT32_MAX ? 0 : ++serial; } static bool occupied(const service_t *s) { - return s->deleting || s->pins || s->status.busy || strcmp(s->status.state, "stopped"); + return s->deleting || s->pins || s->pending_control || s->status.busy || strcmp(s->status.state, "stopped"); } static bool complete(service_t *s, uint32_t registration, uint32_t operation) { if (!s || s->registration != registration || s->operation != operation || !operation) @@ -148,7 +148,7 @@ bool ts_ssh_service_start_admissible(const char *id) { xSemaphoreTake(mutex, portMAX_DELAY); service_t *s = find(id); bool allowed = - !s || (!s->deleting && !s->pins && !s->status.busy && + !s || (!s->deleting && !s->pins && !s->pending_control && !s->status.busy && (!strcmp(s->status.state, "stopped") || (s->status.generation != UINT32_MAX && !strcmp(s->status.source, "none")))); xSemaphoreGive(mutex); @@ -234,15 +234,63 @@ static esp_err_t probe(ts_ssh_session_t session, const char *name, bool stop, bo ts_ssh_exec_result_free(&result); return ret; } +esp_err_t ts_ssh_service_reserve_operation(const char *id, uint32_t registration, ts_service_operation_t kind, uint32_t *operation_id) { + xSemaphoreTake(mutex, portMAX_DELAY); + service_t *s = find(id); + uint32_t token = 0; + if (s && s->registration == registration && !s->deleting && !s->status.busy && !s->pending_control && + (kind != TS_SERVICE_START || (!s->instance[0] && + (!strcmp(s->status.state, "stopped") || !strcmp(s->status.source, "none"))))) + token = ticket(); + if (token) { + s->pending_control = token; + s->status.operation_id = token; + strcpy(s->status.operation_phase, "queued"); + strcpy(s->status.operation_kind, kind == TS_SERVICE_START ? "start" : kind == TS_SERVICE_STOP ? "stop" : "verify"); + s->status.operation_error = ESP_OK; + *operation_id = token; + } + xSemaphoreGive(mutex); + return token ? ESP_OK : ESP_ERR_INVALID_STATE; +} +esp_err_t ts_ssh_service_operation_begin(const char *id, uint32_t operation_id) { + xSemaphoreTake(mutex, portMAX_DELAY); + service_t *s = find(id); + bool current = s && operation_id && s->pending_control == operation_id; + if (current) strcpy(s->status.operation_phase, "executing"); + xSemaphoreGive(mutex); + return current ? ESP_OK : ESP_ERR_INVALID_STATE; +} +bool ts_ssh_service_complete_operation(const char *id, uint32_t operation_id, esp_err_t error) { + xSemaphoreTake(mutex, portMAX_DELAY); + service_t *s = find(id); + bool executed = false; + if (s && operation_id && s->status.operation_id == operation_id) { + executed = !strcmp(s->status.operation_phase, "executing"); + if (s->pending_control == operation_id) s->pending_control = 0; + const char *phase = error == ESP_OK ? "succeeded" : "failed"; + if (error == ESP_ERR_TIMEOUT) + phase = !strcmp(s->status.operation_phase, "queued") ? "expired" : "unconfirmed"; + strcpy(s->status.operation_phase, phase); + s->status.operation_error = error; + } + xSemaphoreGive(mutex); + return executed; +} esp_err_t ts_ssh_service_begin(const ts_ssh_command_config_t *cmd, ts_ssh_session_t session, uint32_t *generation) { + return ts_ssh_service_begin_reserved(cmd, session, 0, generation); +} +esp_err_t ts_ssh_service_begin_reserved(const ts_ssh_command_config_t *cmd, ts_ssh_session_t session, + uint32_t operation_id, uint32_t *generation) { if (!services || !mutex || !cmd || !session || !generation) return ESP_ERR_INVALID_STATE; *generation = 0; ts_ssh_binding_lock(); xSemaphoreTake(mutex, portMAX_DELAY); service_t *s = register_service(cmd, ts_ssh_get_host(session), ts_ssh_get_port(session)); - if (!s || s->status.busy || s->status.generation == UINT32_MAX || + if (!s || (s->pending_control && s->pending_control != operation_id) || + (operation_id && s->pending_control != operation_id) || s->status.busy || s->instance[0] || s->status.generation == UINT32_MAX || !strcmp(s->status.state, "checking") || !strcmp(s->status.state, "starting") || !strcmp(s->status.state, "stopping")) { xSemaphoreGive(mutex); @@ -253,6 +301,7 @@ esp_err_t ts_ssh_service_begin(const ts_ssh_command_config_t *cmd, ts_ssh_sessio if (!operation) { xSemaphoreGive(mutex); ts_ssh_binding_unlock(); return ESP_ERR_INVALID_STATE; } s->operation = operation; s->status.busy = true; + if (operation_id) strcpy(s->status.operation_phase, "executing"); s->status.generation = operation; s->ready_evidence = false; s->instance[0] = 0; @@ -298,7 +347,8 @@ bool ts_ssh_service_finish(const char *id, uint32_t generation, const char *rece bool current = complete(s, registration, generation) && s->status.generation == generation; if (current) { if (launched) strcpy(s->instance, identity); - state_set(s, launched ? "starting" : "unknown", "launch"); + s->pending_control = 0; + state_set(s, launched ? "running" : "unknown", "launch"); } xSemaphoreGive(mutex); return current && launched; @@ -363,7 +413,7 @@ static esp_err_t connect_host(const ts_ssh_host_config_t *snapshot, ts_ssh_sessi ret = ts_ssh_session_create(&cfg, session); return ret == ESP_OK ? ts_ssh_connect(*session) : ret; } -static esp_err_t inspect(const char *id, bool stop, uint32_t recovery, ts_ssh_service_status_t *out) { +static esp_err_t inspect(const char *id, bool stop, uint32_t recovery, uint32_t control, ts_ssh_service_status_t *out) { if (!mutex || !services || !id || !out) return ESP_ERR_INVALID_ARG; ts_ssh_command_config_t *cmd = @@ -390,7 +440,7 @@ static esp_err_t inspect(const char *id, bool stop, uint32_t recovery, ts_ssh_se free(cmd); return ESP_ERR_INVALID_STATE; } - if (s->deleting || s->status.busy || serial == UINT32_MAX || + if (s->deleting || s->status.busy || s->pending_control != control || serial == UINT32_MAX || (recovery && (s->status.generation != recovery || !s->ready_evidence || !s->recovery_left || strcmp(s->status.state, "unknown")))) { *out = s->status; @@ -450,13 +500,18 @@ static esp_err_t inspect(const char *id, bool stop, uint32_t recovery, ts_ssh_se xSemaphoreGive(mutex); return ret; } +esp_err_t ts_ssh_service_execute_control(const char *id, uint32_t operation_id, + ts_service_operation_t kind, ts_ssh_service_status_t *out) { + if (!operation_id || (kind != TS_SERVICE_VERIFY && kind != TS_SERVICE_STOP)) return ESP_ERR_INVALID_ARG; + return inspect(id, kind == TS_SERVICE_STOP, 0, operation_id, out); +} esp_err_t ts_ssh_service_query(const char *id, ts_ssh_service_status_t *out) { - esp_err_t ret = inspect(id, false, 0, out); + esp_err_t ret = inspect(id, false, 0, 0, out); ts_ssh_service_recovery_kick(id); return ret; } esp_err_t ts_ssh_service_stop(const char *id, ts_ssh_service_status_t *out) { - return inspect(id, true, 0, out); + return inspect(id, true, 0, 0, out); } bool ts_ssh_service_binding_busy(const char *id) { if (!mutex || !services) @@ -553,7 +608,7 @@ bool ts_ssh_service_any_in_use(void) { xSemaphoreTake(mutex, portMAX_DELAY); for (unsigned i = 0; i < TS_SSH_COMMANDS_MAX; i++) { service_t *s = &services[i]; - if (s->id[0] && (s->pins || s->status.busy || + if (s->id[0] && (s->pins || s->pending_control || s->status.busy || (strcmp(s->status.source, "none") && strcmp(s->status.state, "stopped")))) busy = true; } @@ -623,7 +678,7 @@ void ts_ssh_service_recovery_kick(const char *id) { } bool ts_ssh_service_recover(const char *id, uint32_t generation) { ts_ssh_service_status_t status; - inspect(id, false, generation, &status); + inspect(id, false, generation, 0, &status); xSemaphoreTake(mutex, portMAX_DELAY); service_t *s = find(id); bool again = s && s->status.generation == generation && s->ready_evidence && diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index 9c21db27..2c7441a0 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -17,6 +17,7 @@ #include "freertos/task.h" #include "esp_http_server.h" #include "esp_system.h" +#include "esp_timer.h" #include #include #include @@ -115,6 +116,33 @@ static esp_err_t check_auth(ts_http_request_t *req, uint32_t *session_id, ts_per #endif } +/* This endpoint retains storage failure details; other response contracts stay unchanged. */ +static esp_err_t send_key_generate_result(ts_http_request_t *req, ts_api_result_t *result, + const char *request_id, int64_t started) +{ + cJSON *response = cJSON_CreateObject(); + bool built = response && cJSON_AddNumberToObject(response, "code", result->code); + if (built && result->message) { + built = cJSON_AddStringToObject(response, result->code == TS_API_OK ? "message" : "error", result->message) != NULL; + } + if (built && result->data) { + cJSON *data = cJSON_Duplicate(result->data, true); + if (!data) built = false; + else if (!cJSON_AddItemToObject(response, "data", data)) { cJSON_Delete(data); built = false; } + } + char *json = built ? cJSON_PrintUnformatted(response) : NULL; + cJSON_Delete(response); + ts_api_result_free(result); + TS_LOGI(TAG, "key.generate request=%s response=%s elapsed_ms=%lld", request_id, + json ? "built" : "allocation_failed", (long long)((esp_timer_get_time() - started) / 1000)); + esp_err_t ret = json ? ts_http_send_json(req, 200, json) + : ts_http_send_error(req, 500, "Key response serialization failed"); + free(json); + TS_LOGI(TAG, "key.generate request=%s send=%s elapsed_ms=%lld", request_id, + esp_err_to_name(ret), (long long)((esp_timer_get_time() - started) / 1000)); + return ret; +} + static esp_err_t api_handler(ts_http_request_t *req, void *user_data) { (void)user_data; @@ -161,8 +189,18 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) // } // } + bool key_generation = strcmp(api_name, "key.generate") == 0; + int64_t key_started = key_generation ? esp_timer_get_time() : 0; + char key_request_id[32] = "legacy"; + if (key_generation) TS_LOGI(TAG, "key.generate received"); // Build request JSON cJSON *request = cJSON_CreateObject(); + if (key_generation && !request) { + TS_LOGE(TAG, "key.generate request=legacy request allocation failed"); + esp_err_t send_ret = ts_http_send_error(req, 500, "Key request allocation failed"); + TS_LOGI(TAG, "key.generate request=legacy send=%s", esp_err_to_name(send_ret)); + return send_ret; + } // Parse URL query string parameters (for GET requests) size_t query_len = httpd_req_get_url_query_len(req->req); @@ -239,6 +277,18 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) } } + if (key_generation) { + const cJSON *rid = cJSON_GetObjectItem(request, "request_id"); + if (cJSON_IsString(rid) && strlen(rid->valuestring) < sizeof(key_request_id)) { + bool valid = rid->valuestring[0] != '\0'; + for (const char *p = rid->valuestring; *p; ++p) { + if (!( (*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) valid = false; + } + if (valid) strcpy(key_request_id, rid->valuestring); + } + TS_LOGI(TAG, "key.generate request=%s arrived", key_request_id); + } // Call API ts_api_result_t result = {0}; esp_err_t ret = ts_api_call(api_name, request, &result); @@ -249,8 +299,15 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) api_name, ret, result.code, result.message ? result.message : "null"); } + if (key_generation) { + const cJSON *generated = cJSON_GetObjectItem(result.data, "generated"); + TS_LOGI(TAG, "key.generate request=%s returned ret=%s code=%d generated_result=%d elapsed_ms=%lld", + key_request_id, esp_err_to_name(ret), result.code, cJSON_IsTrue(generated), + (long long)((esp_timer_get_time() - key_started) / 1000)); + } cJSON_Delete(request); - + if (key_generation) return send_key_generate_result(req, &result, key_request_id, key_started); + if (ret == ESP_OK || result.code == TS_API_OK) { // Build response JSON cJSON *response = cJSON_CreateObject(); diff --git a/components/ts_webui/src/ts_webui_ws.c b/components/ts_webui/src/ts_webui_ws.c index b79cb474..769c4308 100644 --- a/components/ts_webui/src/ts_webui_ws.c +++ b/components/ts_webui/src/ts_webui_ws.c @@ -1222,7 +1222,7 @@ typedef struct { int64_t timer_retry_at; /* maintenance worker owns pacing */ char *password_owned; ts_ssh_config_t config; - char command[512]; + char *command; /* Task-owned full command, including caller wrapping. */ uint32_t session_id; char keyid[64]; /* 存储 keyid 以便获取私钥 */ char var_name[64]; /* 变量名(用于存储结果) */ @@ -1590,6 +1590,7 @@ static void exec_destroy(void *data) ssh_exec_task_params_t *params=data;if(!params)return; assert(!params->session && (!params->timer_phase || params->timer_phase==5)); free((void*)params->config.host);free((void*)params->config.username); + free(params->command); if(params->password_owned){memset(params->password_owned,0,strlen(params->password_owned));free(params->password_owned);} free(params->expect_pattern);free(params->fail_pattern);free(params->extract_pattern); free(params->output_buffer);free(params->extracted_value); @@ -2016,8 +2017,13 @@ static esp_err_t ts_webui_ssh_exec_start_ex_impl(ts_ws_operation_t *op, const ch params->config.timeout_ms = (options && options->timeout_ms > 0) ? options->timeout_ms : 30000; - /* 存储命令 */ - strncpy(params->command, command, sizeof(params->command) - 1); + /* The request body is released before the async task runs. Own the full + * command; truncation can remove shell quoting or change its meaning. */ + size_t command_size = strlen(command) + 1; + params->command = heap_caps_malloc(command_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!params->command) params->command = malloc(command_size); + if (!params->command) return ESP_ERR_NO_MEM; + memcpy(params->command, command, command_size); /* 配置认证方式 */ if (keyid && keyid[0]) { diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index e25e5eaa..c2683f3c 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -240,7 +240,7 @@ input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer- .sys-mid>.card{display:flex;flex-direction:column} .card.in{background:rgba(255,255,255,.62);border-radius:var(--r-inner)} .empty{padding:20px 0;text-align:center;color:var(--ink-3)} -.qa-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:12px} +.qa-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,max(270px,calc((100% - 24px)/3))),1fr));gap:12px} .quick-action-card{display:flex;flex-direction:column;justify-content:space-between;gap:6px;background:rgba(255,255,255,.62);border-radius:var(--r-inner);padding:12px 14px;min-width:0;cursor:default} .quick-action-card[data-allowed=true]:hover{background:rgba(255,255,255,.8)} .quick-action-head,.quick-action-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0} @@ -249,34 +249,47 @@ input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer- .quick-action-service-status{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%} .quick-action-service-status::before{content:"";flex:none;width:8px;height:8px;border-radius:50%;background:var(--ink-3);opacity:.55} .quick-action-service-status .service-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit} -.quick-action-card[data-state=running] .quick-action-service-status::before{background:var(--ok-dot);opacity:1} +.quick-action-card[data-state=ready] .quick-action-service-status::before{background:var(--ok-dot);opacity:1} .quick-action-card[data-state=starting] .quick-action-service-status::before,.quick-action-card[data-state=stopping] .quick-action-service-status::before{background:var(--warn-dot);opacity:1} .quick-action-card[data-state=error] .quick-action-service-status::before,.quick-action-card[data-state=failed] .quick-action-service-status::before{background:var(--bad-dot);opacity:1} -.quick-action-card[data-state=running] .quick-action-service-status{color:var(--ok)} +.quick-action-card[data-state=ready] .quick-action-service-status{color:var(--ok)} .quick-action-card[data-state=error] .quick-action-service-status,.quick-action-card[data-state=failed] .quick-action-service-status{color:var(--bad)} .quick-action-nohup-bar{display:flex;flex:none;gap:8px} -.dw-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px} +.dw-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} +.dw-card{container-type:inline-size;min-width:0;overflow-wrap:anywhere} .dw-card>.t-label{display:flex;align-items:center;gap:6px} +.dw-card>.t-label>.i{flex:none} .dw-card[data-type=log][data-layout=auto]{grid-column:span 2} .dw-layout-medium{grid-column:span 2} .dw-layout-large{grid-column:span 3} .dw-layout-full{grid-column:1/-1} -.dw-ring{display:flex;justify-content:center;padding:8px 0 2px} +.dw-ring{display:grid;place-items:center;width:100%;min-height:64px;margin:8px 0 2px} +.dw-ring>svg,.dw-ring-value{grid-area:1/1} +.dw-ring>svg{max-width:100%} +.dw-ring-value{z-index:1;width:100%;min-width:0;text-align:center;font-size:14px;line-height:20px;font-weight:500;overflow-wrap:anywhere} +.dw-gauge{min-height:44px;align-items:end} .bigrow.w32{margin:10px 0 0} -.w32 .t-big{font-size:32px;line-height:36px} +.dw-card .bigrow{flex-wrap:wrap;max-width:100%} +.dw-card .bigrow>span,.dw-card .kv>span,.dw-ic>.t-body,.dw-st>.state{min-width:0;max-width:100%;overflow-wrap:anywhere;white-space:normal} +.dw-card .w32 .t-big{font-size:clamp(16px,10cqi,32px);line-height:1.125;letter-spacing:0} .dw-st{margin-top:14px} -.dw-ic{display:flex;align-items:center;gap:8px;margin-top:10px} +.dw-ic{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px} +.dw-icon-display{flex:none} .dw-tx{margin-top:10px;font-weight:500;word-break:break-word} .dw-log-h{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap} -.dw-log-tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.dw-log-tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-width:0;max-width:100%} +.dw-log-tools>.btn,.dw-log-tools>.state{flex-shrink:0;max-width:100%;white-space:normal;overflow-wrap:anywhere} .dw-log-container{margin-top:10px;max-height:220px;overflow:auto;background:#1e1f2b;border-radius:var(--r-inner);padding:10px 12px;color:#e6e6ef;font:12px/18px var(--mono)} .dw-log-collapsed{display:none} .dw-log-empty{color:#9a9ca8} -.dw-log-line{white-space:pre-wrap;word-break:break-all} +.dw-log-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dw-log-error-line{color:#ff8a8a} .dw-log-warn-line{color:#ffcf7a} .dw-log-info-line{color:#8ec5ff} .dw-log-debug-line{color:#9a9ca8} +@container (max-width:240px){.dw-card[data-type=dual] .bigrow{flex-direction:column;align-items:center}} +@media (max-width:1200px){.dw-grid{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media (max-width:900px){.dw-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dw-grid>.dw-layout-large{grid-column:span 2}} .fan-panel{min-width:0} .fan-body{flex:1;display:flex;flex-direction:column;gap:10px} .fan-body>.fan-test{margin-top:auto} @@ -477,6 +490,13 @@ textarea.field{height:auto;padding:8px 12px;width:100%;resize:vertical}textarea. .mtable td{padding:10px 12px 10px 0;border-top:1px solid var(--hair);font-variant-numeric:tabular-nums} .mtable th.sortable{cursor:pointer} .logv{height:200px;font-size:12px;line-height:18px;overflow-y:auto} +.lpmu-log{margin-top:16px} +.lpmu-log[open]{padding-bottom:16px} +.lpmu-log-toggle{list-style:none;color:var(--ink);cursor:default} +.lpmu-log-toggle::-webkit-details-marker{display:none} +.lpmu-log-toggle .i{color:var(--ink-3)} +.lpmu-log[open]>.lpmu-log-toggle .i{transform:rotate(180deg)} +.lpmu-log-output pre{margin:0;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere} #modal-log-container{height:320px} .log-entry{display:grid;grid-template-columns:70px 50px 100px 1fr;gap:8px;color:#8ee59a} .log-tag{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -558,5 +578,26 @@ input[type=color].swi::-webkit-color-swatch{border:0;border-radius:50%} .seg button{padding:0 8px} .sys-top,.sys-mid,.fans-grid,.led-grid,.ota-partitions,.kv-grid.k2,.match-grid{grid-template-columns:1fr} .qa-grid,.kv-grid.k4{grid-template-columns:repeat(2,1fr)} -.dw-grid{grid-template-columns:repeat(2,1fr)} +.dw-grid{grid-template-columns:minmax(0,1fr)} +.dw-grid>.dw-layout-medium,.dw-grid>.dw-layout-large,.dw-grid>.dw-card[data-type=log][data-layout=auto]{grid-column:span 1} } + +/* Both service views render the same backend state, including transitions away + from ready. A green indicator means confirmed readiness, not submission. */ +.quick-action-service-status::before { + content: ''; width: 7px; height: 7px; border-radius: 50%; + background: currentColor; flex-shrink: 0; +} +.quick-action-service-status.status-unknown, +.quick-action-service-status.status-stopped, +.service-status.status-unknown, +.service-status.status-stopped { color: var(--ink-3); } +.quick-action-service-status.status-starting, +.quick-action-service-status.status-running, +.quick-action-service-status.status-stopping, +.service-status.status-starting, +.service-status.status-running, +.service-status.status-stopping { color: var(--accent-text); } +.service-status.status-ready { color: var(--ok); } +.service-status.status-failed, +.service-status.status-timeout { color: var(--bad); } diff --git a/components/ts_webui/web/favicon.ico b/components/ts_webui/web/favicon.ico index 8b7dc9b1..79635462 100644 Binary files a/components/ts_webui/web/favicon.ico and b/components/ts_webui/web/favicon.ico differ diff --git a/components/ts_webui/web/images/README.md b/components/ts_webui/web/images/README.md index 6bb56799..72612f4b 100644 --- a/components/ts_webui/web/images/README.md +++ b/components/ts_webui/web/images/README.md @@ -11,14 +11,17 @@ | `tslogo-256.png` | 256x256 | 中高分辨率 | 应用图标、大型展示 | | `tslogo-128.png` | 128x128 | 标准分辨率 | 应用列表图标 | | `tslogo-64.png` | 64x64 | 中等图标 | 工具栏、通知 | -| `tslogo-48.png` | 48x48 | Header 导航栏 | WebUI 顶部 Logo | -| `tslogo-32.png` | 32x32 | 小图标 | 按钮、标签页图标 | -| `favicon.ico` | 多尺寸 | 浏览器标签图标 | 浏览器 favicon | -| `favicon.icns` | 多尺寸 | macOS 图标格式 | 中间产物 | +| `tslogo-48.png` | 48x48 | 旧版彩色 Logo | 保留资源 | +| `tslogo-32.png` | 32x32 | 旧版彩色小图标 | 保留资源 | +| `favicon.svg` | 矢量 | 当前页头山形,透明底黑色线条 | 标签图标生成源 | +| `../favicon.ico` | 16/32/48/64/128/256 | 透明底黑色线条 | 浏览器标签图标 | +| `favicon.icns` | 多尺寸 | 旧版 macOS 图标格式 | 保留资源 | ## 设计说明 -Logo 采用渐变背景设计: +浏览器标签图标与当前页头山形一致,采用透明背景、纯黑线条。彩色 PNG 为旧版资源,保留供其他场景使用,不再作为标签图标。 + +旧版 Logo 采用渐变背景设计: - **颜色渐变**:从左下角的青色 (#00BCD4) 渐变到右上角的紫色 (#9C27B0) - **主体图形**:黑色山形轮廓,象征"天山"主题 - **圆角设计**:柔和的圆角矩形背景,现代化风格 @@ -28,11 +31,8 @@ Logo 采用渐变背景设计: ### HTML ```html - -TianshanOS Logo - - + ``` ### CSS diff --git a/components/ts_webui/web/images/favicon.svg b/components/ts_webui/web/images/favicon.svg new file mode 100644 index 00000000..7850829d --- /dev/null +++ b/components/ts_webui/web/images/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index 3e57428e..d93c3208 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -7,12 +7,9 @@ - - - - - - + + + @@ -91,7 +88,7 @@ - + - + diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index db9f9e4e..b34d6b37 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -52,7 +52,8 @@ function apiErrorMessage(result, operation = '') { 'File not found': 'fileMissing', 'Directory not found': 'directoryMissing', 'Source file not found': 'fileMissing', 'Cannot delete root directory': 'rootDirectory', 'Failed to stat file': 'fileInfo', 'Memory allocation failed': 'memory', - 'Request timeout': 'timeout', 'Failed to fetch': 'connection' + 'Request timeout': 'timeout', 'Failed to fetch': 'connection', + 'service_delete_protected': 'serviceDeleteProtected' }; const codes = ['unknownError', 'invalidInput', 'notFound', 'permission', 'busy', 'timeout', 'memory', 'internal', 'unsupported', 'hardware', 'connection', 'auth']; @@ -113,6 +114,13 @@ class TianShanAPI { localStorage.removeItem('ts_expires'); } + // Only the latest generation is retained; no credentials or key material. + recordKeyGeneration(stage, fields = {}, requestId = this.keyGenerationTrace?.requestId) { + const trace = this.keyGenerationTrace; + if (!trace || trace.requestId !== requestId) return; + if (trace.events.length < 32) trace.events.push({stage, at: Date.now(), ...fields}); + } + /** * 通用 API 请求方法 */ @@ -142,14 +150,22 @@ class TianShanAPI { } let httpStatus; + const keyRequestId = endpoint === '/key/generate' && method === 'POST' ? data?.request_id : null; + const traceKey = (stage, fields) => { + if (keyRequestId) this.recordKeyGeneration(stage, fields, keyRequestId); + }; + traceKey('fetch_start'); try { const response = await fetch(getApiUrl(endpoint), options); httpStatus = response.status; + traceKey('response_headers', {httpStatus}); const text = await response.text(); + traceKey('response_body', {length: text.length}); let json; try { json = JSON.parse(text); } catch (parseErr) { + if (keyRequestId) throw parseErr; if (!response.ok) throw new ApiOperationError({error: text}, endpoint, {httpStatus: response.status}); // 响应可能被 WebSocket 等数据污染(如 ~{"type"...),尝试从某处解析出带 code 的 API 格式 if (parseErr instanceof SyntaxError && text) { @@ -170,6 +186,7 @@ class TianShanAPI { } } + traceKey('response_parsed', {code: typeof json?.code === 'number' ? json.code : null, generated: json?.data?.generated === true}); // 返回 JSON 响应,即使是错误码也返回(让调用者决定如何处理) if (!response.ok) throw new ApiOperationError(json, endpoint, {httpStatus: response.status}); const logoutResponse = endpoint === '/auth/logout' && method === 'POST'; @@ -195,6 +212,7 @@ class TianShanAPI { return json; } catch (error) { + traceKey('request_error', {kind: controller.signal.aborted ? 'timeout' : error.kind || (error instanceof SyntaxError ? 'format' : httpStatus >= 400 ? 'http' : 'network'), httpStatus}); if (error instanceof ApiOperationError) throw error; const kind = controller.signal.aborted ? 'timeout' : error instanceof SyntaxError ? 'format' : 'network'; throw new ApiOperationError({message: error.message}, endpoint, {kind, uncertain: method !== 'GET', httpStatus}); @@ -447,8 +465,9 @@ class TianShanAPI { // 综合网络状态 (包含 ethernet, wifi_sta, wifi_ap) async networkStatus() { return this.call('network.status'); } - async lpmuAccessStart() { return this.call('network.lpmu_access.start', null, 'POST'); } + async lpmuAccessStart(sudoPassword) { return this.call('network.lpmu_access.start', { sudo_password: sudoPassword }, 'POST'); } async lpmuAccessStatus() { return this.call('network.lpmu_access.status'); } + async lpmuAccessLog(runId, offset = 0) { return this.call('network.lpmu_access.log', { run_id: runId, offset }, 'GET'); } // WiFi 相关 async wifiMode(mode = null) { @@ -605,7 +624,7 @@ class TianShanAPI { async sshCopyid(host, user, password, keyid, port = 22, verify = true, options = {}) { return this.call('ssh.copyid', { host, user, password, keyid, port, verify, - trust_new: options.trust_new ?? false, + trust_new: options.trust_new ?? true, // 部署沿用 0.5.1:首次自动信任新主机 confirmed_fingerprint: options.confirmed_fingerprint, accept_changed: options.accept_changed ?? false }, 'POST'); @@ -630,7 +649,10 @@ class TianShanAPI { async keyList() { return this.call('key.list'); } async keyInfo(id) { return this.call('key.info', { id }); } async keyGenerate(id, type = 'rsa2048', comment = '', exportable = false, alias = '', hidden = false) { - return this.call('key.generate', { id, type, comment, exportable, alias, hidden }, 'POST'); + const request_id = 'kg-' + Date.now().toString(36) + '-' + (this.keyGenerationSequence = (this.keyGenerationSequence || 0) + 1).toString(36); + this.keyGenerationTrace = {requestId: request_id, id, events: []}; + this.recordKeyGeneration('submit'); + return this.call('key.generate', { id, type, comment, exportable, alias, hidden, request_id }, 'POST'); } async keyDelete(id) { return this.call('key.delete', { id }, 'POST'); } async keyExport(id) { return this.call('key.export', { id }); } diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 947c5bc2..79144989 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -587,7 +587,7 @@ const swc = (id, on = false, extra = '') => `${desc}` : ''}${extra}
`, - ``); + ``); const importPlaceholder = key => row(t('securityPage.previewRowLabel'), `${t('securityPage.previewAfterSelect')}`) + row(t('ssh.overwriteExisting'), swc(`import-${key}-overwrite`)); const importSheet = (key, title, desc, preview, confirm, hide, extra = '', w = 560, ok = t('ssh.confirmImport')) => sheet(w, title, `
${desc}
${t('common.noFileSelected')}
@@ -737,7 +737,7 @@ async function loadSystemPage() {
${t('fan.effectiveTemp')} -- - ${t('fan.targetSpeed')} -- + ${t('fan.currentOutput')} --
${t('common.loading')}
@@ -1418,6 +1418,11 @@ function updatePowerInfo(data) { } // 更新风扇信息 +const fanSpeedDrafts = new Map(); +function fanOutputKnown(fan) { + return fan.duty_valid !== false && Number.isFinite(fan.duty) && fan.duty >= 0 && fan.duty <= 100; +} + function updateFanInfo(data) { const container = document.getElementById('fans-grid'); @@ -1428,24 +1433,36 @@ function updateFanInfo(data) { globalTempEl.textContent = `${typeof data.temperature === 'number' ? data.temperature.toFixed(1) : '--'} °C`; globalTempEl.classList.toggle('warn', !data.temp_valid); } - if (globalDutyEl && data?.fans?.length > 0) { - // 显示第一个曲线模式风扇的目标转速,或平均值 - const curveFan = data.fans.find(f => f.mode === 'curve' || f.mode === 'auto'); - if (curveFan) { - globalDutyEl.textContent = `${curveFan.target_duty ?? curveFan.duty ?? 0}%`; - } else { - const avgDuty = Math.round(data.fans.reduce((s, f) => s + (f.duty ?? 0), 0) / data.fans.length); - globalDutyEl.textContent = `${avgDuty}%`; - } + if (globalDutyEl) { + const fans = data?.fans || []; + const curveFan = fans.find(f => f.mode === 'curve' || f.mode === 'auto'); + const shown = curveFan ? [curveFan] : fans; + const known = shown.length > 0 && shown.every(fanOutputKnown); + globalDutyEl.textContent = known ? `${Math.round(shown.reduce((sum, f) => sum + f.duty, 0) / shown.length)}%` : '--%'; + } + + // Polling must not replace a range input under the user's pointer. + const activeSlider = document.activeElement; + const activeFan = data?.fans?.find(f => activeSlider?.id === `fan-slider-${f.id}`); + if (activeFan?.mode === 'manual' && fanOutputKnown(activeFan) && fanSpeedDrafts.has(activeFan.id)) { + const card = activeSlider.closest('.fan-card'); + card.querySelector('.fan-speed-num').textContent = activeFan.duty; + const state = card.querySelector('.state'); + state.textContent = activeFan.fault ? t('fan.adjustmentFailed') : t('fanPage.modeManual'); + state.className = activeFan.fault ? 'state bad' : 'state ok'; + return; } if (data?.fans && data.fans.length > 0) { container.innerHTML = data.fans.map(fan => { const mode = fan.mode || 'auto'; - // 曲线/自动模式显示目标转速 (target_duty),手动模式显示当前转速 (duty) - const isCurveOrAuto = (mode === 'curve' || mode === 'auto'); - const displayDuty = isCurveOrAuto ? (fan.target_duty ?? fan.duty ?? 0) : (fan.duty ?? 0); - const duty = fan.duty ?? 0; // 当前实际转速(用于滑块) + // 所有模式的大读数都与已成功下发的 PWM 一致。 + const known = fanOutputKnown(fan); + const displayDuty = known ? fan.duty : '--'; + if (mode !== 'manual' || !known) fanSpeedDrafts.delete(fan.id); + const draft = fanSpeedDrafts.get(fan.id); + const duty = draft?.value ?? (known ? fan.duty : 0); + const sliderText = `${draft?.value ?? displayDuty}%`; const rpm = fan.rpm || 0; const isManual = mode === 'manual'; const isOff = mode === 'off'; @@ -1464,8 +1481,8 @@ function updateFanInfo(data) { guard: t('fanPage.autoStateGuard'), stale: t('fanPage.autoStateStale'), unknown: t('fanPage.autoStateUnknown') }; const autoState = fan.auto_state || 'unknown'; - const stateText = mode === 'auto' ? (autoStateLabels[autoState] || autoState) : ({ off: _off, manual: _manual, curve: _curve }[mode] || mode); - const stateCls = isOff ? '' : fan.guard_active ? ' warn' : fan.temp_stale ? ' bad' : ' ok'; + const stateText = !known ? t('fan.outputUnknown') : fan.fault ? t('fan.adjustmentFailed') : mode === 'auto' ? (autoStateLabels[autoState] || autoState) : ({ off: _off, manual: _manual, curve: _curve }[mode] || mode); + const stateCls = !known || fan.fault ? ' bad' : isOff ? '' : fan.guard_active ? ' warn' : fan.temp_stale ? ' bad' : ' ok'; const stat = (label, value) => `${label}${value}`; const meta = '
' + (hasAutoTelemetry ? ( (typeof fan.guard_temperature === 'number' ? stat(t('fanPage.guardTempBrief'), `${fan.guard_temperature.toFixed(1)} °C`) : '') + @@ -1483,9 +1500,11 @@ function updateFanInfo(data) {
${tab('off', _off)}${tab('manual', _manual)}${tab('curve', _curve)}${tab('auto', _smart)}
${t('fanPage.speedAdjust')} -
- ${duty}% +
+ ${sliderText}
+
${draft ? t('fan.draftSetting', {speed: draft.value}) : ''}
+ ${known && Number.isFinite(fan.target_duty) && fan.target_duty !== fan.duty ? `
${t('fan.requestedSetting', {speed: fan.target_duty})}
` : ''} ${meta}
`; @@ -1520,14 +1539,14 @@ function updateFanSliderUI(fanId, value) { const slider = document.getElementById(`fan-slider-${fanId}`); if (!slider) return; + fanSpeedDrafts.set(fanId, {value: Number(value)}); const card = slider.closest('.fan-card'); if (card) { - // 更新大数字 - const numSpan = card.querySelector('.fan-speed-num'); - if (numSpan) numSpan.textContent = value; // 更新滑块旁边的值 const valSpan = card.querySelector('.fan-slider-value'); if (valSpan) valSpan.textContent = value + '%'; + const draftSpan = card.querySelector('.fan-draft'); + if (draftSpan) draftSpan.textContent = t('fan.draftSetting', {speed: value}); slider.parentElement.style.setProperty('--p', value + '%'); } } @@ -1619,10 +1638,31 @@ function hideServicesModal() { } async function setFanSpeed(id, speed) { + const draft = fanSpeedDrafts.get(id); + const requested = Number(speed); try { - requireApiSuccess(await api.fanSet(id, parseInt(speed)), 'fanSet'); - showToast(typeof t === 'function' ? t('fan.speedSet', { id, speed }) : `风扇 ${id} 速度已设置为 ${speed}%`, 'success'); - } catch (e) { showToast((typeof t === 'function' ? t('fan.setFanFailed', { msg: e.message }) : '设置风扇失败: ' + e.message), 'error'); } + const result = await api.fanSet(id, requested); + if (!result || result.code !== 0 || (result.httpStatus && result.httpStatus >= 400)) { + console.error('fan.set rejected', result); + showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + } else { + showToast(t(result.data?.enabled === false ? 'fan.settingDisabled' : 'fan.speedSet', {id, speed: requested}), 'success'); + } + if (fanSpeedDrafts.get(id) === draft) fanSpeedDrafts.delete(id); + await refreshFans(); + } catch (e) { + console.error('fan.set result unavailable', e); + if (fanSpeedDrafts.get(id) === draft) fanSpeedDrafts.delete(id); + if (e instanceof ApiOperationError && e.code !== undefined && !e.uncertain) { + showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + await refreshFans(); + return; + } + showToast(t('fan.settingUnknown'), 'warning'); + // Do not repeat a command whose outcome is unknown. A later status poll + // supplies a current snapshot; it cannot prove the outcome of this command. + markFanOutputsUnknown(id); + } } async function setFanMode(id, mode) { @@ -1633,13 +1673,32 @@ async function setFanMode(id, mode) { } catch (e) { showToast((typeof t === 'function' ? t('fan.setFanModeFailed', { msg: e.message }) : '设置风扇模式失败: ' + e.message), 'error'); } } +function markFanOutputsUnknown(id = null) { + if (id === null) fanSpeedDrafts.clear(); + else fanSpeedDrafts.delete(id); + for (const slider of document.querySelectorAll('.fan-slider')) { + if (id !== null && slider.id !== `fan-slider-${id}`) continue; + const card = slider.closest('.fan-card'); + card.querySelector('.fan-speed-num').textContent = '--'; + card.querySelector('.fan-slider-value').textContent = '--%'; + card.querySelector('.fan-draft').textContent = ''; + card.querySelector('.state').textContent = t('fan.outputUnknown'); + card.querySelector('.state').className = 'state bad'; + slider.disabled = true; + } + const global = document.getElementById('fan-global-duty'); + if (global) global.textContent = '--%'; +} + async function refreshFans() { try { - const result = await api.call('fan.status'); - if (result.data) { - updateFanInfo(result.data); - } - } catch (e) { console.error('刷新风扇状态失败:', e); } + const result = requireApiSuccess(await api.call('fan.status'), 'fan.status'); + if (result.data) updateFanInfo(result.data); + else markFanOutputsUnknown(); + } catch (e) { + console.error('fan.status unavailable:', e); + markFanOutputsUnknown(); + } } /*===========================================================================*/ @@ -2791,7 +2850,7 @@ function loadDataWidgetsFromLocalStorage() { return oldWidgets.map(w => ({ ...w, expression: w.variable ? `\${${w.variable}}` : null, - decimals: w.decimals || 1 + decimals: w.decimals ?? 1 })); } } @@ -2978,10 +3037,10 @@ function renderWidgetHtml(widget) { switch (type) { case 'ring': - contentHtml = `
-
`; + contentHtml = `
-
`; break; case 'gauge': - contentHtml = `
-
`; + contentHtml = `
-
`; break; case 'temp': contentHtml = big(`-°C`); @@ -3543,7 +3602,7 @@ function addWidgetFromPreset(presetId) { unit: preset.unit, min: 0, max: 100, - decimals: WIDGET_TYPES[preset.type]?.defaultConfig?.decimals || 1, + decimals: WIDGET_TYPES[preset.type]?.defaultConfig?.decimals ?? 1, expression: null }; @@ -3573,9 +3632,9 @@ function createNewWidget(type) { icon: typeConfig.icon, color: defaults.color || '#4dabf7', unit: defaults.unit || '', - min: defaults.min || 0, - max: defaults.max || 100, - decimals: defaults.decimals || 1, + min: defaults.min ?? 0, + max: defaults.max ?? 100, + decimals: defaults.decimals ?? 1, expression: null }; @@ -3615,7 +3674,7 @@ function showWidgetEditPanel(widgetId) { // 额外配置(根据组件类型) let extraRows = ''; if (widget.type === 'status') { - extraRows = row(t('dataWidget.thresholdSettings'), num('edit-threshold-1', 64, widget.thresholds?.[0] || 0) + num('edit-threshold-2', 64, widget.thresholds?.[1] || 50) + num('edit-threshold-3', 64, widget.thresholds?.[2] || 80)); + extraRows = row(t('dataWidget.thresholdSettings'), num('edit-threshold-1', 64, widget.thresholds?.[0] ?? 0) + num('edit-threshold-2', 64, widget.thresholds?.[1] ?? 50) + num('edit-threshold-3', 64, widget.thresholds?.[2] ?? 80)); } if (widget.type === 'dual') { extraRows = row(t('dataWidget.secondaryExpression'), inp('edit-expression2', 220, t('dataWidget.secondaryExpressionPlaceholder'), 'mono', `value="${escapeHtml(widget.expression2 || '')}"`), '', t('dataWidget.secondaryExpressionHint')); @@ -3645,9 +3704,9 @@ function showWidgetEditPanel(widgetId) { ${grp(layoutRows)} ${widget.type !== 'log' ? gt(t('dataWidget.unit')) + grp( row(t('dataWidget.unit'), inp('edit-unit', 90, '%、°C、W', '', `value="${escapeHtml(widget.unit || '')}"`)) + - row(t('dataWidget.decimals'), num('edit-decimals', 70, widget.decimals || 1, 'min="0" max="4"')) + + row(t('dataWidget.decimals'), num('edit-decimals', 70, widget.decimals ?? 1, 'min="0" max="4"')) + (widget.type !== 'text' && widget.type !== 'icon' && widget.type !== 'status' ? - row(t('dataWidget.minValue'), num('edit-min', 90, widget.min || 0)) + row(t('dataWidget.maxValue'), num('edit-max', 90, widget.max || 100)) : '')) : ''} + row(t('dataWidget.minValue'), num('edit-min', 90, widget.min ?? 0)) + row(t('dataWidget.maxValue'), num('edit-max', 90, widget.max ?? 100)) : '')) : ''} ${extraRows ? grp(extraRows, 'margin-top:8px') : ''}
@@ -3675,49 +3734,42 @@ async function selectVariableForWidget() { * 保存组件编辑 */ function saveWidgetEdit(widgetId) { + clearFieldErrors(); const widget = dataWidgets.find(w => w.id === widgetId); if (!widget) return; - widget.label = document.getElementById('edit-label')?.value?.trim() || widget.label; - widget.icon = document.getElementById('edit-icon')?.value?.trim() || ''; - widget.color = document.getElementById('edit-color')?.value || '#4dabf7'; - - // 布局 - const layoutRadio = document.querySelector('input[name="edit-layout"]:checked'); - widget.layout = layoutRadio?.value || 'auto'; - - // 非日志组件的通用属性 - if (widget.type !== 'log') { - widget.unit = document.getElementById('edit-unit')?.value?.trim() || ''; - widget.decimals = parseInt(document.getElementById('edit-decimals')?.value) || 1; - widget.min = parseFloat(document.getElementById('edit-min')?.value) || 0; - widget.max = parseFloat(document.getElementById('edit-max')?.value) || 100; - widget.expression = document.getElementById('edit-expression')?.value?.trim() || null; - } - - // 额外配置 - if (widget.type === 'status') { - widget.thresholds = [ - parseFloat(document.getElementById('edit-threshold-1')?.value) || 0, - parseFloat(document.getElementById('edit-threshold-2')?.value) || 50, - parseFloat(document.getElementById('edit-threshold-3')?.value) || 80 - ]; - } - if (widget.type === 'dual') { - widget.expression2 = document.getElementById('edit-expression2')?.value?.trim() || null; - } - if (widget.type === 'log') { - widget.maxLines = parseInt(document.getElementById('edit-max-lines')?.value) || 15; - widget.refreshInterval = parseInt(document.getElementById('edit-refresh-interval')?.value) || 2000; - widget.expression = document.getElementById('edit-expression')?.value?.trim() || null; - - // 如果日志正在读取,重启定时器以应用新配置 - if (widget._isReading) { - stopLogReading(widgetId); - startLogReading(widgetId); + const next = { ...widget }; + next.label = document.getElementById('edit-label')?.value?.trim() || widget.label; + next.icon = document.getElementById('edit-icon')?.value?.trim() || ''; + next.color = document.getElementById('edit-color')?.value || '#4dabf7'; + next.layout = document.querySelector('input[name="edit-layout"]:checked')?.value || 'auto'; + try { + if (widget.type !== 'log') { + next.unit = document.getElementById('edit-unit')?.value?.trim() || ''; + next.decimals = readNumericInput('edit-decimals', true); + if (document.getElementById('edit-min')) next.min = readNumericInput('edit-min'); + if (document.getElementById('edit-max')) next.max = readNumericInput('edit-max'); + if (['ring', 'gauge', 'bar'].includes(widget.type) && next.min >= next.max) { + fieldError('edit-max', t('inputRepair.minMax')); + return; + } + } + if (widget.type === 'status') { + next.thresholds = [1, 2, 3].map(n => readNumericInput('edit-threshold-' + n)); } + if (widget.type === 'log') { + next.maxLines = readNumericInput('edit-max-lines', true); + next.refreshInterval = readNumericInput('edit-refresh-interval', true); + } + } catch (_) { return; } + next.expression = document.getElementById('edit-expression')?.value?.trim() || null; + if (widget.type === 'dual') next.expression2 = document.getElementById('edit-expression2')?.value?.trim() || null; + Object.assign(widget, next); + if (widget.type === 'log' && widget._isReading) { + stopLogReading(widgetId); + startLogReading(widgetId); } - + saveDataWidgets(); renderDataWidgets(); renderWidgetManagerList(); @@ -3819,6 +3871,7 @@ async function refreshQuickActions() { statusHtml = `${runtimeText('unknown')}`; nohupBtns = `
+
`; @@ -3875,7 +3928,7 @@ function startServiceStatusRefresh() { // 每 3 秒刷新一次服务状态 serviceStatusRefreshInterval = setInterval(() => { - const statusContainers = document.querySelectorAll('.quick-action-service-status'); + const statusContainers = document.querySelectorAll('.quick-action-service-status, .service-mode-status .service-status'); if (statusContainers.length === 0) { stopServiceStatusRefresh(); return; @@ -3892,33 +3945,107 @@ function stopServiceStatusRefresh() { } let serviceStatusInFlight = false; -async function updateQuickActionServiceStatus() { +const serviceStateVersions = new Map(); +function advanceServiceState(commandId) { + const version = (serviceStateVersions.get(commandId) || 0) + 1; + serviceStateVersions.set(commandId, version); + return version; +} +const serviceVerifyInFlight = new Set(); +const serviceStates = ['unknown', 'starting', 'checking', 'running', 'ready', 'stopping', 'stopped', 'timeout', 'failed']; +function renderServiceState(element, data = {}) { + const launching = data.operation_phase === 'queued' || data.operation_phase === 'executing'; + const state = launching ? (data.operation_kind === 'stop' ? 'stopping' : data.operation_kind === 'verify' ? 'checking' : 'starting') : serviceStates.includes(data.state) ? data.state : 'unknown'; + for (const value of serviceStates) element.classList.remove('status-' + value); + element.classList.add('status-' + state); + const label = element.querySelector('.service-value') || element; + label.textContent = runtimeText(launching ? (data.operation_phase === 'queued' ? 'controlQueued' : data.operation_kind === 'verify' ? 'controlVerifying' : state) : state); + element.title = runtimeText('stateEvidence') + ': ' + (data.source || 'none') + + (data.confirmed_ms ? ' · ' + runtimeText('confirmedAt') + ' ' + Math.floor(data.confirmed_ms / 1000) + 's' : ''); + const card = element.closest('.quick-action-card'); + if (card) card.dataset.state = state; +} +function renderServiceCommand(commandId, data) { + for (const element of document.querySelectorAll('.quick-action-service-status, .service-mode-status .service-status')) + if (element.dataset.command === commandId) renderServiceState(element, data); +} +function renderServiceOperation(commandId, data) { + const output = document.getElementById('exec-result'); + if (!output || output.dataset.serviceCommand !== commandId || !output.dataset.operationId || !data.operation_id) return; + const matches = Number(output.dataset.operationId) === data.operation_id; + const phase = matches ? data.operation_phase : 'operationReplaced'; + if (!phase || phase === output.dataset.operationPhase) return; + output.dataset.operationPhase = phase; + output.textContent += '\n' + runtimeText(phase === 'expired' ? 'controlExpired' : 'launch_' + phase) + + (matches && data.operation_error ? ' (' + data.operation_error + ')' : ''); +} +async function refreshServiceStates() { if (document.hidden || serviceStatusInFlight) return; serviceStatusInFlight = true; try { - for (const container of document.querySelectorAll('.quick-action-service-status')) { - if (!container.isConnected || document.hidden) break; - const card = container.closest('.quick-action-card'); - const commandId = container.dataset.command; + for (const element of document.querySelectorAll('.quick-action-service-status, .service-mode-status .service-status')) { + const commandId = element.dataset.command; + const revision = serviceStateVersions.get(commandId); + if (!element.isConnected || document.hidden) break; + if (serviceVerifyInFlight.has(commandId)) continue; + let data = {}; try { const response = await api.call('automation.services.status', { command_id: commandId }); - if (!container.isConnected || container.dataset.command !== commandId) continue; - const data = response.data || {}; - const state = response.code === 0 ? data.state : 'unknown'; - card.dataset.state = state; - container.querySelector('.service-value').textContent = runtimeText(state) + - (data.last_known && state === 'unknown' ? ' · ' + runtimeText('lastKnown') + ': ' + runtimeText(data.last_known) : '') + - (data.confirmed_ms ? ' · ' + runtimeText('confirmedAt') + ' ' + Math.floor(data.confirmed_ms / 1000) + 's' : ''); - container.title = runtimeText('stateEvidence') + ': ' + (data.source || 'none'); - } catch (_) { - if (container.isConnected) { - card.dataset.state = 'unknown'; - container.querySelector('.service-value').textContent = runtimeText('unknown'); - } + if (response.code === 0) data = response.data || {}; + } catch (_) {} + if (element.isConnected && revision === serviceStateVersions.get(commandId) && !serviceVerifyInFlight.has(commandId)) { + renderServiceState(element, data); + renderServiceOperation(commandId, data); } } } finally { serviceStatusInFlight = false; } } +async function updateQuickActionServiceStatus() { + await refreshServiceStates(); +} +// Explicit controls poll only the local operation record. No automatic resubmission. +async function waitServiceOperation(commandId, operationId, isCurrent) { + const deadline = Date.now() + 60000; + while (isCurrent() && Date.now() < deadline) { + const response = requireApiSuccess(await api.call('automation.services.status', {command_id: commandId}), 'automation.services.status'); + if (!isCurrent()) return null; + const data = response.data || {}; + if (data.operation_id !== operationId) throw new Error(runtimeText('launch_operationReplaced')); + if (data.operation_phase === 'succeeded') return data; + if (data.operation_phase === 'expired') throw new Error(runtimeText('controlExpired')); + if (data.operation_phase === 'failed' || data.operation_phase === 'unconfirmed') + throw new Error(runtimeText('outcomeUnknown')); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + if (isCurrent()) throw new Error(runtimeText('outcomeUnknown')); + return null; +} +async function requestServiceControl(commandId, stop, isCurrent) { + const endpoint = stop ? 'automation.services.stop' : 'automation.services.status'; + const params = stop ? {command_id: commandId} : {command_id: commandId, verify: true}; + const response = requireApiSuccess(await api.call(endpoint, params, 'POST'), endpoint); + if (!Number.isInteger(response.data?.operation_id) || response.data.operation_id <= 0) + throw new ApiOperationError({}, endpoint, {kind: 'format', uncertain: true}); + return waitServiceOperation(commandId, response.data.operation_id, isCurrent); +} +async function verifyServiceState(commandId) { + if (serviceVerifyInFlight.has(commandId)) return; + serviceVerifyInFlight.add(commandId); + const revision = advanceServiceState(commandId); + const pageCurrent = capturePageValidity(); + const isCurrent = () => pageCurrent() && revision === serviceStateVersions.get(commandId); + try { + const data = await requestServiceControl(commandId, false, isCurrent); + if (!data || !isCurrent()) return; + renderServiceCommand(commandId, data); + showToast(runtimeText(data.state || 'unknown'), 'info'); + return data; + } catch (e) { + if (!isCurrent()) return; + renderServiceCommand(commandId, {}); + showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); + } finally { advanceServiceState(commandId); serviceVerifyInFlight.delete(commandId); } +} // 触发快捷操作后的冷却时间(毫秒),避免连续触发导致后端只执行最后一个 let _quickActionTriggerCooldownUntil = 0; @@ -3927,6 +4054,7 @@ let quickActionsTimeoutId = null; // 用于导航时取消,避免 quick-actio /** 供 router 在页面切换时取消快捷操作定时器,并销毁拖拽排序(防止 ghost 残留) */ window.stopSystemPageTimers = function() { + releaseExecDisplay(); clearInterval(refreshInterval); refreshInterval = null; clearInterval(localTimeInterval); @@ -3962,16 +4090,6 @@ async function triggerQuickAction(ruleId) { return; } - // A user start request may verify unknown state. Periodic refresh remains local. - if (card.dataset.allowed === 'true' && card.dataset.service && card.dataset.state === 'unknown') { - try { - const checked = await api.call('automation.services.status', { command_id: card.dataset.service, verify: true }); - card.dataset.state = checked.code === 0 ? checked.data?.state || 'unknown' : 'unknown'; - } catch (_) { card.dataset.state = 'unknown'; } - } - if (card.dataset.allowed !== 'true' || (card.dataset.service && card.dataset.state !== 'stopped')) { - showToast(runtimeText('startBlocked'), 'warning'); return; - } if (card.classList.contains('triggering')) { showToast(typeof t === 'function' ? t('toast.processing') : 'Operation in progress...', 'warning'); return; @@ -3984,11 +4102,26 @@ async function triggerQuickAction(ruleId) { } try { - // 添加按下效果并禁用点击 + // Keep verification and triggering within the same in-flight operation. + if (card.dataset.service) advanceServiceState(card.dataset.service); card.classList.add('triggering'); + card.style.pointerEvents = 'none'; + + // A user start request may verify unknown state. Periodic refresh remains local. + if (card.dataset.allowed === 'true' && card.dataset.service && card.dataset.state === 'unknown') { + try { + const checked = await requestServiceControl(card.dataset.service, false, + () => card.isConnected && document.getElementById(`quick-action-${ruleId}`) === card); + if (!checked) return; + card.dataset.state = checked.state || 'unknown'; + } catch (_) { card.dataset.state = 'unknown'; } + } + if (card.dataset.allowed !== 'true' || (card.dataset.service && card.dataset.state !== 'stopped')) { + showToast(runtimeText('startBlocked'), 'warning'); return; + } + if (card.dataset.service) card.dataset.state = 'starting'; - card.style.pointerEvents = 'none'; // 禁用点击防止重复 - + // 更新图标显示加载状态 const iconEl = card.querySelector('.quick-action-icon'); const originalIcon = iconEl?.innerHTML; @@ -4003,26 +4136,23 @@ async function triggerQuickAction(ruleId) { _quickActionLastTriggeredId = ruleId; _quickActionTriggerCooldownUntil = Date.now() + 5000; // 5 秒内勿触发其他规则,避免后端串行导致第二个未执行 card.classList.add('is-running'); - card.style.pointerEvents = ''; setTimeout(() => refreshQuickActions(), 2500); } else { showToast((result.message || (typeof t === 'function' ? t('toast.execFailed') : '执行失败')), 'error'); - card.style.pointerEvents = ''; // 失败时恢复点击 // 恢复原始图标 if (iconEl && originalIcon) { iconEl.innerHTML = originalIcon; } } - card.classList.remove('triggering'); } catch (e) { console.error('triggerQuickAction error:', e); showToast(runtimeText('outcomeUnknown'), 'error'); if (card?.dataset.service) card.dataset.state = 'unknown'; - if (card) { - card.classList.remove('triggering'); - card.style.pointerEvents = ''; - } + } finally { + if (card.dataset.service) advanceServiceState(card.dataset.service); + card.classList.remove('triggering'); + card.style.pointerEvents = ''; } } @@ -4325,21 +4455,26 @@ function closeQuickLogModal() { /** * 快捷操作 - 终止进程(基于 PID 文件精确停止) - * 支持杀进程组(vLLM 等多进程服务),SIGTERM → 等待 → SIGKILL 回退 + * 后端验证实例身份并停止进程组,页面仅显示核验结果。 */ const serviceStopInFlight = new Set(); async function quickActionStopProcess(commandId, automatic) { if (serviceStopInFlight.has(commandId)) return; if (!await confirmAction(runtimeText(automatic ? 'confirmStopAutomatic' : 'confirmStop'), { primary: t('common.stop'), tone: 'neutral' })) return; serviceStopInFlight.add(commandId); - for (const card of document.querySelectorAll('.quick-action-card')) - if (card.dataset.service === commandId) card.dataset.state = 'stopping'; + const revision = advanceServiceState(commandId); + const pageCurrent = capturePageValidity(); + const isCurrent = () => pageCurrent() && revision === serviceStateVersions.get(commandId); + renderServiceCommand(commandId, {state: 'stopping'}); try { - const result = await api.call('automation.services.stop', { command_id: commandId }); - const stopped = result.code === 0 && result.data?.state === 'stopped'; + const data = await requestServiceControl(commandId, true, isCurrent); + if (!data || !isCurrent()) return; + renderServiceCommand(commandId, data); + const stopped = data.state === 'stopped'; showToast(runtimeText(stopped ? 'stopped' : 'outcomeUnknown'), stopped ? 'success' : 'warning'); - } catch (_) { showToast(runtimeText('outcomeUnknown'), 'warning'); } - finally { serviceStopInFlight.delete(commandId); await updateQuickActionServiceStatus(); } + } catch (e) { + if (isCurrent()) { renderServiceCommand(commandId, {}); showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); } + } finally { advanceServiceState(commandId); serviceStopInFlight.delete(commandId); await updateQuickActionServiceStatus(); } } // 时间同步功能 @@ -5209,7 +5344,7 @@ function selectFilterInModal(filter, btn) { if (config && config.params && config.params.length > 0) { paramsDiv.innerHTML = config.params.map(param => { const paramInfo = paramLabels[param]; - const defaultValue = config.defaults[param] || 50; + const defaultValue = config.defaults[param] ?? 50; return row(getParamLabel(param), ledSlider(`modal-filter-${param}`, paramInfo.min, paramInfo.max, defaultValue, `document.getElementById('modal-filter-${param}-val').textContent=this.value+'${paramInfo.unit}'`) + ledVal(`modal-filter-${param}-val`, defaultValue + paramInfo.unit)); }).join(''); } else { @@ -5238,10 +5373,9 @@ async function applyFilterFromModal() { if (input) { let value = parseInt(input.value); // \u6839\u636e\u53c2\u6570\u7c7b\u578b\u8f6c\u6362\u503c - if (param === 'saturation') { - value = Math.round(value * 2.55); // 0-100 \u8f6c 0-255 - } else if (param === 'decay') { + if (param === 'saturation' || param === 'frequency') { value = Math.round(value * 2.55); // 0-100 \u8f6c 0-255 + } else if (param === 'amount') { value = value - 50; // 0-100 \u8f6c -50 to +50 } @@ -5776,7 +5910,7 @@ async function loadFilePickerDirectory(path) { const upto = '/' + parts.slice(0, i + 1).join('/'); return `/` + (i === parts.length - 1 ? `${escapeHtml(seg)}` - : `${escapeHtml(seg)}`); + : `${escapeHtml(seg)}`); }).join(''); const listContainer = document.getElementById('file-picker-list'); listContainer.innerHTML = '
' + t('common.loading') + '
'; @@ -5788,7 +5922,7 @@ async function loadFilePickerDirectory(path) { if (result.error) { // 目录不存在,尝试创建 if (result.error.includes('not found') || result.error.includes('Directory')) { - listContainer.innerHTML = `
${t('filePage.dirNotExist')}
`; + listContainer.innerHTML = `
${t('filePage.dirNotExist')}
`; return; } throw new Error(result.error); @@ -5821,7 +5955,7 @@ async function loadFilePickerDirectory(path) { listContainer.innerHTML = filtered.map(f => { const isDir = f.type === 'dir' || f.type === 'directory'; const fullPath = path + (path.endsWith('/') ? '' : '/') + f.name; - return `
${escapeHtml(f.name)}
${isDir ? '-' : formatFileSize(f.size)}
`; + return `
${escapeHtml(f.name)}
${isDir ? '-' : formatFileSize(f.size)}
`; }).join(''); } catch (e) { listContainer.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(e.message)}
`; @@ -6114,7 +6248,7 @@ function selectFilter(filterName, btnElement) { config.params.forEach(param => { const paramInfo = paramLabels[param]; - const defaultValue = config.defaults[param] || 50; + const defaultValue = config.defaults[param] ?? 50; const row = document.createElement('div'); row.className = 'config-row'; @@ -6170,10 +6304,9 @@ async function applySelectedFilter() { if (input) { let value = parseInt(input.value); // 根据参数类型转换值 - if (param === 'saturation') { - value = Math.round(value * 2.55); // 0-100 转 0-255 - } else if (param === 'decay') { + if (param === 'saturation' || param === 'frequency') { value = Math.round(value * 2.55); // 0-100 转 0-255 + } else if (param === 'amount') { value = value - 50; // 0-100 转 -50 to +50 } @@ -6227,7 +6360,9 @@ async function stopFilter() { const NETWORK_LPMU_ACCESS_POLL_MS = 1500; let networkLpmuAccessPollTimer = null; -let networkLpmuAccessRequesting = false; +let networkLpmuAccessRequesting = null; +let networkLpmuAccessSubmitting = null; +let networkLpmuAccessUncertain = false; let networkLpmuAccessLastInfo = { stage: 'idle', running: false }; async function loadNetworkPage() { @@ -6406,9 +6541,9 @@ async function loadNetworkPage() { await refreshNetworkPage(); if (!pageCurrent()) return; - await refreshNetworkLpmuAccessStatus(); + await refreshNetworkLpmuAccessStatus(false); if (!pageCurrent()) return; - if (networkLpmuAccessLastInfo?.running) { + if (!networkLpmuAccessUncertain && (networkLpmuAccessLastInfo?.running || networkLpmuAccessSubmitting)) { startNetworkLpmuAccessPolling(); } } @@ -6575,15 +6710,137 @@ async function refreshNetworkPage() { } async function startNetworkLpmuAccess() { - if (networkLpmuAccessLastInfo?.running) return; + if (networkLpmuAccessSubmitting) return; + if (networkLpmuAccessUncertain) { + const pageCurrent = capturePageValidity(); + await refreshNetworkLpmuAccessStatus(); + if (!pageCurrent()) return; + if (!networkLpmuAccessUncertain && networkLpmuAccessLastInfo.running) startNetworkLpmuAccessPolling(); + return; + } + if (networkLpmuAccessLastInfo?.running || document.getElementById('network-lpmu-password-modal')) return; + const modal = document.createElement('div'); + modal.id = 'network-lpmu-password-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', t('networkPage.lpmuAccessTitle')); + const showLabel = t('securityPage.showPassword'); + modal.innerHTML = sheet(480, t('networkPage.lpmuAccessTitle'), + `
${t('networkPage.lpmuAccessPasswordHint')}
` + + grp(row(t('networkPage.lpmuAccessSudoPassword'), + `
`)), + ``); + modal.onclick = e => { if (e.target === modal) closeNetworkLpmuPasswordModal(); }; + modal.onkeydown = e => { + if (e.key === 'Escape') { e.preventDefault(); closeNetworkLpmuPasswordModal(); } + else if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); submitNetworkLpmuAccess(); } + else if (e.key === 'Tab') { + const controls = [...modal.querySelectorAll('input, button')]; + const first = controls[0], last = controls.at(-1); + if (e.shiftKey && e.target === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && e.target === last) { e.preventDefault(); first.focus(); } + } + }; + document.body.appendChild(modal); + const input = document.getElementById('network-lpmu-sudo-password'); + router.navigation?.onDispose(() => { input.value = ''; modal.remove(); }); + input.focus(); +} +function closeNetworkLpmuPasswordModal() { + const input = document.getElementById('network-lpmu-sudo-password'); + if (input) input.value = ''; + document.getElementById('network-lpmu-password-modal')?.remove(); + document.getElementById('network-lpmu-access-btn')?.focus(); +} + +function showNetworkLpmuAccessResult(info) { + document.getElementById('network-lpmu-result-modal')?.remove(); + const modal = document.createElement('div'); + modal.id = 'network-lpmu-result-modal'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', t('networkPage.lpmuAccessTitle')); + const close = () => { modal.remove(); document.getElementById('network-lpmu-access-btn')?.focus(); }; + modal.innerHTML = sheet(480, t('networkPage.lpmuAccessTitle'), + `
${t(info.success ? 'networkPage.lpmuAccessSuccess' : 'networkPage.lpmuAccessFailed')}
` + + `
${escapeHtml(info.summary).replace(/\n/g, '
')}
` + + (info.success ? grp(row(t('networkPage.lpmuAccessInternet'), `${t('networkPage.lpmuAccessInternetConfirmed')}`) + + row(t('networkPage.lpmuAccessConfiguration'), `${t('networkPage.lpmuAccessConfigurationDone')}`)) : '') + + `
${t('networkPage.lpmuAccessLog')}
`, + ``); + const details = modal.querySelector('details'); + let finished = false, loading = false, offset = 0, text = ''; + const technicalDetail = info.technicalError ? t('networkPage.lpmuAccessTechnicalDetail', {error: info.technicalError}) + '\n\n' : ''; + details.ontoggle = async () => { + if (!modal.isConnected) return; + modal.querySelector('.sheet').style.width = details.open ? '720px' : '480px'; + if (!details.open || finished || loading) return; + loading = true; + const output = modal.querySelector('pre'); + output.textContent = t('common.loading'); + try { + for (;;) { + const result = requireApiSuccess(await api.lpmuAccessLog(info.runId, offset), 'network.lpmu_access.log'); + if (!modal.isConnected) return; + const data = result.data; + if (!data || data.run_id !== info.runId || typeof data.output !== 'string' || + typeof data.done !== 'boolean' || !Number.isSafeInteger(data.next_offset) || + data.next_offset < offset || (!data.done && data.next_offset === offset)) { + throw new Error(t('networkPage.lpmuAccessLogInvalidPage')); + } + text += data.output; + offset = data.next_offset; + finished = data.done; + if (finished || !details.open) { + output.textContent = technicalDetail + (text || t('networkPage.lpmuAccessLogEmpty')); + return; + } + } + } catch (error) { + finished = true; // Reopening must not automatically retry an invalid transcript. + if (modal.isConnected) output.textContent = technicalDetail + t('networkPage.lpmuAccessLogFailed') + ': ' + error.message; + } finally { loading = false; } + }; + modal.querySelector('button').onclick = close; + modal.onclick = e => { if (e.target === modal) close(); }; + modal.onkeydown = e => { + if (e.key === 'Escape') { e.preventDefault(); close(); } + else if (e.key === 'Tab') { + const first = modal.querySelector('summary'), last = modal.querySelector('button'); + if (e.shiftKey && e.target === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && e.target === last) { e.preventDefault(); first.focus(); } + } + }; + document.body.appendChild(modal); + router.navigation?.onDispose(() => modal.remove()); + modal.querySelector('button').focus(); +} + +async function submitNetworkLpmuAccess() { + if (networkLpmuAccessLastInfo?.running || networkLpmuAccessSubmitting || networkLpmuAccessUncertain) return; + const pageCurrent = capturePageValidity(); + const input = document.getElementById('network-lpmu-sudo-password'); + if (!input) return; + let password = input.value; + if (!password || /[\r\n]/.test(password)) { + fieldError(input, t('networkPage.lpmuAccessPasswordRequired')); + return; + } + closeNetworkLpmuPasswordModal(); + stopNetworkLpmuAccessPolling(); + const submission = {}; + networkLpmuAccessSubmitting = submission; renderNetworkLpmuAccessStatus({ stage: 'check', status: 'running', running: true }); try { - const result = await api.lpmuAccessStart(); - if (!result || result.code !== 0) { - throw new Error(result?.message || (typeof t === 'function' ? t('networkPage.lpmuAccessStartFailed') : '启动接入失败')); - } + const request = api.lpmuAccessStart(password); + password = ''; + const result = requireApiSuccess(await request, 'network.lpmu_access.start'); + if (!pageCurrent()) return; + networkLpmuAccessSubmitting = null; let data = result.data || {}; if (data === null || typeof data !== 'object') { @@ -6597,10 +6854,23 @@ async function startNetworkLpmuAccess() { startNetworkLpmuAccessPolling(); await refreshNetworkLpmuAccessStatus(); } catch (e) { + if (!pageCurrent()) return; + networkLpmuAccessSubmitting = null; stopNetworkLpmuAccessPolling(); const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); - renderNetworkLpmuAccessStatus({ stage: 'failed', status: 'failed', error: msg, running: false }); - showToast((typeof t === 'function' ? t('networkPage.lpmuAccessFailed') : '接入失败') + ': ' + escapeHtml(msg), 'error', 5000); + if (typeof e.code === 'number' && e.code !== 0 && !e.uncertain) { + showToast(t('networkPage.lpmuAccessStartFailed') + ': ' + msg, 'error', 5000); + // A rejected start can mean another task is already active. Read + // its state rather than inventing a failed remote execution. + await refreshNetworkLpmuAccessStatus(false); + if (!pageCurrent()) return; + if (!networkLpmuAccessUncertain && networkLpmuAccessLastInfo.running) startNetworkLpmuAccessPolling(); + } else { + showNetworkLpmuAccessStatusUnknown(msg); + } + } finally { + password = ''; + if (networkLpmuAccessSubmitting === submission) networkLpmuAccessSubmitting = null; } } @@ -6610,14 +6880,14 @@ function startNetworkLpmuAccessPolling() { } function stopNetworkLpmuAccessPolling() { - networkLpmuAccessRequesting = false; + networkLpmuAccessRequesting = null; if (networkLpmuAccessPollTimer) { clearInterval(networkLpmuAccessPollTimer); networkLpmuAccessPollTimer = null; } } -async function refreshNetworkLpmuAccessStatus() { +async function refreshNetworkLpmuAccessStatus(notifyResult = true) { const pageCurrent = capturePageValidity(); if (networkLpmuAccessRequesting) return; if (!document.getElementById('network-lpmu-access-btn')) { @@ -6626,45 +6896,56 @@ async function refreshNetworkLpmuAccessStatus() { } const wasRunning = !!networkLpmuAccessLastInfo?.running; - networkLpmuAccessRequesting = true; + const request = { runId: networkLpmuAccessLastInfo.runId }; + networkLpmuAccessRequesting = request; + const requestCurrent = () => pageCurrent() && networkLpmuAccessRequesting === request && + networkLpmuAccessLastInfo.runId === request.runId; try { const result = await api.lpmuAccessStatus(); - if (!pageCurrent()) return; + if (!requestCurrent()) return; if (!result || result.code !== 0) { throw new Error(result?.message || (typeof t === 'function' ? t('networkPage.lpmuAccessStatusFailed') : '状态获取失败')); } const info = renderNetworkLpmuAccessStatus(result.data || {}); - if (!info.running) { + if (!info.running && !networkLpmuAccessSubmitting) { stopNetworkLpmuAccessPolling(); - if (wasRunning && info.success) { - showToast(typeof t === 'function' ? t('networkPage.lpmuAccessSuccess') : '接入成功', 'success'); - } else if (wasRunning && info.failed) { - const msg = info.summary ? ': ' + escapeHtml(info.summary) : ''; - showToast((typeof t === 'function' ? t('networkPage.lpmuAccessFailed') : '接入失败') + msg, 'error', 5000); + if (notifyResult && wasRunning && request.runId === info.runId && (info.success || info.failed)) { + showNetworkLpmuAccessResult(info); } } } catch (e) { - if (!pageCurrent()) return; + if (!requestCurrent()) return; stopNetworkLpmuAccessPolling(); const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); - renderNetworkLpmuAccessStatus({ stage: 'failed', status: 'failed', error: msg, running: false }); - if (wasRunning) { - showToast((typeof t === 'function' ? t('networkPage.lpmuAccessStatusFailed') : '状态获取失败') + ': ' + escapeHtml(msg), 'error', 5000); - } + showNetworkLpmuAccessStatusUnknown(msg); } finally { - if (pageCurrent()) networkLpmuAccessRequesting = false; + if (networkLpmuAccessRequesting === request) networkLpmuAccessRequesting = null; + } +} + +function showNetworkLpmuAccessStatusUnknown(message) { + networkLpmuAccessUncertain = true; + const btn = document.getElementById('network-lpmu-access-btn'); + if (btn) { + btn.disabled = !!networkLpmuAccessSubmitting; + btn.textContent = t('networkPage.lpmuAccessRefreshStatus'); } + const current = document.getElementById('network-lpmu-access-current'); + if (current) current.innerHTML = '' + escapeHtml(t('networkPage.lpmuAccessStatusUnknown')) + ''; + const summary = document.getElementById('network-lpmu-access-summary'); + if (summary) summary.textContent = message; } function renderNetworkLpmuAccessStatus(statusData = {}, fallbackMessage = '') { const info = normalizeNetworkLpmuAccessStatus(statusData, fallbackMessage); networkLpmuAccessLastInfo = info; + networkLpmuAccessUncertain = false; const btn = document.getElementById('network-lpmu-access-btn'); if (btn) { - btn.disabled = !!info.running; + btn.disabled = !!info.running || !!networkLpmuAccessSubmitting; btn.innerHTML = info.running ? (typeof t === 'function' ? t('networkPage.lpmuAccessRunning') : '处理中') : (typeof t === 'function' ? t('networkPage.lpmuAccessBtn') : '通过LPMU接入'); @@ -6701,23 +6982,46 @@ function normalizeNetworkLpmuAccessStatus(statusData = {}, fallbackMessage = '') const raw = String(data.stage || data.step || data.status || data.state || '').toLowerCase(); let stage = normalizeNetworkLpmuAccessStage(raw, data); - const failed = stage === 'failed' || !!data.error || !!data.last_error; - const success = stage === 'success'; let running = data.running === true || data.busy === true; if (!running && ['check', 'upload', 'unpack', 'execute'].includes(stage)) { running = data.running !== false && data.done !== true; } - if (success || failed || stage === 'idle') { - running = false; - } + const failed = !running && (stage === 'failed' || !!data.error || !!data.last_error); + const success = !running && stage === 'success' && !failed; + if (running && (stage === 'success' || stage === 'failed')) stage = 'execute'; const errorText = data.error || data.last_error || data.stderr; const safeFallback = fallbackMessage && fallbackMessage !== 'OK' ? fallbackMessage : ''; const outputText = data.summary || data.output_tail || data.output || data.stdout || data.message || safeFallback; - const summary = compactNetworkLpmuAccessText(failed ? (errorText || outputText) : (outputText || errorText)); - - return { stage, running, success, failed, summary }; + const unconfirmed = failed && data.exit_code === 0 && + (data.internet_confirmed === false || data.configuration_complete === false); + const primaryError = compactNetworkLpmuAccessText(errorText || + (Number.isInteger(data.exit_code) && data.exit_code >= 0 ? t('networkPage.lpmuAccessExitCode', {code: data.exit_code}) : '')); + const diagnosis = compactNetworkLpmuAccessOutput(outputText || data.stderr); + const scriptFailed = /^setup-smart-route failed \(exit=\d+\): remote script failed$/.test(primaryError); + const scriptError = scriptFailed ? extractNetworkLpmuAccessScriptError(outputText) : ''; + const summary = success ? t('networkPage.lpmuAccessResultDescription') : + unconfirmed ? t('networkPage.lpmuAccessUnconfirmed') : failed ? + scriptFailed ? (scriptError || diagnosis || t('networkPage.lpmuAccessFailed')) : + [primaryError, diagnosis && diagnosis !== primaryError ? t('networkPage.lpmuAccessLastOutput', {output: diagnosis}) : ''].filter(Boolean).join('\n') : ''; + + return { stage, running, success, failed, summary, technicalError: failed ? primaryError : '', runId: data.run_id }; +} + +function extractNetworkLpmuAccessScriptError(value) { + const lines = String(value || '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').split(/\r?\n/); + // The script explicitly labels its error; route tables printed afterward + // are diagnostic context, not the reason for failure. + for (let i = lines.length - 1; i >= 0; i--) { + const match = lines[i].trim().match(/^✗\s*错误[::]\s*(.+)$/); + if (match) return match[1] === '没有找到可用的互联网连接' ? + t('networkPage.lpmuAccessNoInternet') : compactNetworkLpmuAccessText(match[1]); + } + if (lines.some(line => /^sudo: \d+ incorrect password attempts?$/.test(line.trim()))) { + return t('networkPage.lpmuAccessPasswordIncorrect'); + } + return ''; } function normalizeNetworkLpmuAccessStage(raw, data = {}) { @@ -6755,6 +7059,15 @@ function compactNetworkLpmuAccessText(value) { return text.slice(0, 157) + '...'; } +function compactNetworkLpmuAccessOutput(value) { + if (value === null || value === undefined) return ''; + const text = String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .split(/\r?\n/).map(line => line.trim()) + .filter(line => line && !/^={3,}/.test(line) && !/^\$ /.test(line)) + .slice(-2).join(' ').replace(/\s+/g, ' '); + return text.length <= 160 ? text : '…' + text.slice(-159); +} + // 更新接口状态样式 function updateIfaceStatus(elementId, isActive) { const el = document.getElementById(elementId); @@ -6861,6 +7174,19 @@ function clearFieldErrors() { document.querySelectorAll('.err[aria-invalid]').forEach(e => { e.classList.remove('err'); e.removeAttribute('aria-invalid'); }); } +function readNumericInput(id, integer = false) { + const el = document.getElementById(id); + const raw = el?.value?.trim() ?? ''; + const value = Number(raw); + if (!raw || !Number.isFinite(value) || (integer && !Number.isInteger(value)) || + (el.min !== '' && el.min !== undefined && value < Number(el.min)) || + (el.max !== '' && el.max !== undefined && value > Number(el.max))) { + fieldError(id, t('inputRepair.invalidNumber')); + throw new RangeError('Invalid numeric input: ' + id); + } + return value; +} + function fieldError(id, message) { clearFieldErrors(); const el = typeof id === 'string' ? document.getElementById(id) : id; @@ -7367,13 +7693,13 @@ async function loadDirectory(path) { const fullPath = path + '/' + entry.name; const isDir = entry.type === 'dir'; const size = isDir ? '-' : formatFileSize(entry.size); - const escapedPath = fullPath.replace(/'/g, "\\'").replace(/"/g, '"'); - const escapedName = entry.name.replace(/'/g, "\\'").replace(/"/g, '"'); + const escapedPath = escapeHtml(fullPath); + const escapedName = escapeHtml(entry.name); const isSelected = selectedFiles.has(fullPath); return `
-
-
${entry.name}
+
+
${escapeHtml(entry.name)}
${size}
${isDir ? '' : ``}
@@ -7480,7 +7806,7 @@ function updateBreadcrumb(path) { parts.forEach((part, i) => { currentPath += '/' + part; - html += `/`; + html += `/`; }); container.innerHTML = html; @@ -7929,6 +8255,34 @@ async function saveSshCommandToBackend(hostId, cmdData, cmdId) { * 从后端删除 SSH 指令 * @param {string} cmdId - 指令 ID(如 "AGX_Power_On" 或 "cmd_xxxxxxxx") */ +// Deletion needs fresh remote evidence: a missing registry entry is unknown, +// including after a controller reboot. Reuse queued verification, never stop here. +const configurationDeletesInFlight = new Set(); +async function verifyStoppedServiceForDelete(commandId, pageCurrent) { + const revision = advanceServiceState(commandId); + const isCurrent = () => pageCurrent() && revision === serviceStateVersions.get(commandId); + try { + const response = await api.call('ssh.commands.get', { id: commandId }); + if (!isCurrent()) return false; + // A stale template can still be removed after its command has disappeared. + if (response.code === 2) return true; + const command = requireApiSuccess(response, 'ssh.commands.get').data || {}; + if (!command.nohup || !command.serviceMode) return true; + const data = await requestServiceControl(commandId, false, isCurrent); + if (!data || !isCurrent()) return false; + renderServiceCommand(commandId, data); + if (data.state !== 'stopped' || data.busy) + throw new ApiOperationError({code: 4, error: 'service_delete_protected'}); + return true; + } catch (error) { + if (!isCurrent()) return false; + throw error; + } finally { + // Retire this result and its in-flight reads without invalidating a newer control. + if (isCurrent()) advanceServiceState(commandId); + } +} + async function deleteSshCommandFromBackend(cmdId) { requireApiSuccess(await api.call('ssh.commands.remove', { id: cmdId }), 'call'); } @@ -7960,8 +8314,8 @@ async function loadCommandsPage() { stopServiceStatusRefresh(); - // 重置执行状态(防止页面切换后状态残留) - currentExecSessionId = null; + // Leave remote execution untouched when replacing the page. + releaseExecDisplay(); // 加载已保存的指令(从后端) await loadSshCommands(); @@ -8194,7 +8548,7 @@ function refreshCommandsList() { } // 操作列宽按最多的按钮数定,保证各行对齐(默认 执行 / 编辑 / 删除 三个 = 96px) - const extraCount = c => (c.varName ? 1 : 0) + (c.nohup && c.serviceMode ? 2 : 0) + 1; + const extraCount = c => (c.varName ? 1 : 0) + (c.nohup && c.serviceMode ? 3 : 0) + 1; const nBtn = 3 + Math.max(...hostCommands.map(extraCount)); const cols = `style="--cols:32px 1fr 1.6fr ${nBtn * 28 + (nBtn - 1) * 6}px"`; @@ -8211,7 +8565,7 @@ function refreshCommandsList() { if (cmd.nohup && cmd.serviceMode) { // 服务模式:显示服务状态(用 cmd.id 作唯一标识,避免多个服务时 ID 冲突) const statusId = `service-status-${cmd.id || idx}`; - tagsHtml += `...`; + tagsHtml += `...`; } // 图标:RemixIcon 类名、图片路径或旧版 Emoji @@ -8228,9 +8582,9 @@ function refreshCommandsList() { // 服务模式按钮(日志、停止) const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || String(cmd.id).replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; const serviceActions = (cmd.nohup && cmd.serviceMode) - ? icoBtn('ri-file-text-line', t('sshPage.viewLog'), `viewServiceLog(${idx}, '${escapeHtml(safeName)}')`) + icoBtn('ri-stop-line', t('sshPage.stopService'), `stopServiceProcess(${idx}, '${escapeHtml(safeName)}')`, 'dg') + ? icoBtn('ri-refresh-line', runtimeText('verifyState'), `verifyServiceState('${escapeHtml(cmd.id)}')`) + icoBtn('ri-file-text-line', t('sshPage.viewLog'), `viewServiceLog(${idx}, '${escapeHtml(safeName)}')`) + icoBtn('ri-stop-line', t('sshPage.stopService'), `stopServiceProcess(${idx})`, 'dg') : ''; - const varBtn = cmd.varName ? icoBtn('ri-bar-chart-line', escapeHtml(t('promptRepair.viewVariables', {name: cmd.varName})), `showCommandVariables('${escapeHtml(cmd.varName)}')`) : ''; + const varBtn = cmd.varName ? `` : ''; const runTitle = isOrphan ? t('ssh.hostNotExistCannotExec') : t('common.run'); const runBtn = ``; @@ -8248,6 +8602,9 @@ function refreshCommandsList() {
`}).join(''); + container.querySelectorAll('.cmd-variables').forEach(button => { + button.addEventListener('click', () => showCommandVariables(button.dataset.variable)); + }); // 更新服务模式状态 updateServiceStatusInList(); } @@ -8257,35 +8614,8 @@ function refreshCommandsList() { * 查询每个服务模式指令的变量状态并更新显示 */ async function updateServiceStatusInList() { - const serviceModeTags = document.querySelectorAll('.service-mode-status'); - if (serviceModeTags.length === 0) return; - - for (const tag of serviceModeTags) { - const varName = tag.dataset.var; - const statusId = tag.dataset.statusId; - const statusEl = document.getElementById(statusId); - - if (!varName || !statusEl) { - continue; - } - - try { - const result = await api.call('automation.variables.get', { name: `${escapeHtml(varName)}.status` }); - - if (result && result.data && result.data.value !== undefined) { - const status = result.data.value; - statusEl.textContent = getServiceStatusLabel(status); - statusEl.className = 'state' + (status === 'ready' ? ' ok' : status === 'failed' || status === 'timeout' ? ' bad' : ''); - } else { - statusEl.textContent = (typeof t === 'function' ? t('sshPage.statusIdle') : '未启动'); - statusEl.className = 'state'; - } - } catch (e) { - console.error(`[ServiceStatus] Error getting ${escapeHtml(varName)}.status:`, e); - statusEl.textContent = (typeof t === 'function' ? t('sshPage.statusUnknown') : '未知'); - statusEl.className = 'state'; - } - } + await refreshServiceStates(); + if (document.querySelector('.service-mode-status')) startServiceStatusRefresh(); } /** @@ -8400,7 +8730,7 @@ async function showCommandVariables(varName) { try { const result = await api.call('automation.variables.list', { - prefix: `${escapeHtml(varName)}.`, + prefix: `${varName}.`, include_meta: true }); if (result.code === 0 && result.data && result.data.variables) { @@ -8872,9 +9202,10 @@ function showExportSshCommandModal(cmdId) { document.body.appendChild(modal); } - modal.innerHTML = exportSheet('ssh-cmd', t('sshPage.exportSshCmdTitle'), t('sshPage.exportSshCmdDesc', {cmdId: escapeHtml(cmdId)}), t('securityPage.targetCertHint'), 'hideExportSshCommandModal', `doExportSshCommandFromModal('${escapeHtml(cmdId)}')`, + modal.innerHTML = exportSheet('ssh-cmd', t('sshPage.exportSshCmdTitle'), t('sshPage.exportSshCmdDesc', {cmdId: escapeHtml(cmdId)}), t('securityPage.targetCertHint'), 'hideExportSshCommandModal', 'doExportSshCommandFromModal', grp(row(t('ssh.includeHostConfig'), swc('export-ssh-cmd-include-host', true), '', t('ssh.includeHostConfigHint'))) + '
'); + modal.dataset.exportId = cmdId; modal.classList.remove('hidden'); } @@ -9090,30 +9421,74 @@ async function confirmSshCommandImport() { } async function deleteCommand(idx) { - const cmd = sshCommands[selectedHostId]?.[idx]; + const hostId = selectedHostId; + const cmd = sshCommands[hostId]?.[idx]; if (!cmd) return; - - if (!await confirmAction(typeof t === 'function' ? t('ui.confirmDeleteCmd', { name: cmd.name }) : `确定要删除指令「${cmd.name}」吗?`, { primary: t('common.delete'), tone: 'danger' })) return; - + const key = 'command:' + cmd.id; + if (configurationDeletesInFlight.has(key)) return; + configurationDeletesInFlight.add(key); + const pageCurrent = capturePageValidity(); try { - // 从后端删除(需要指令 ID) + if (!await confirmAction(typeof t === 'function' ? t('ui.confirmDeleteCmd', { name: cmd.name }) : `确定要删除指令「${cmd.name}」吗?`, { primary: t('common.delete'), tone: 'danger' }) || !pageCurrent()) return; if (cmd.id) { + showToast(t('toast.processing'), 'info'); + if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent) || !pageCurrent()) return; await deleteSshCommandFromBackend(cmd.id); } - - // 从本地缓存删除 - sshCommands[selectedHostId].splice(idx, 1); - refreshCommandsList(); + if (!pageCurrent()) return; + const commands = sshCommands[hostId]; + const currentIndex = commands?.findIndex(item => item === cmd || (cmd.id && item.id === cmd.id)); + if (currentIndex >= 0) commands.splice(currentIndex, 1); + if (selectedHostId === hostId) refreshCommandsList(); showToast((typeof t === 'function' ? t('toast.commandDeleted') : '指令已删除'), 'success'); } catch (e) { - console.error('Failed to delete command:', e); - showToast((typeof t === 'function' ? t('toast.deleteCommandFailedMsg', { msg: e.message }) : '删除指令失败: ' + e.message), 'error'); + if (pageCurrent()) showToast((typeof t === 'function' ? t('toast.deleteCommandFailedMsg', { msg: e.message }) : '删除指令失败: ' + e.message), 'error'); + } finally { + configurationDeletesInFlight.delete(key); } } /* 当前执行中的会话 ID */ let currentExecSessionId = null; +// A display belongs to one local invocation, then one acknowledged server operation. +let execDisplayOwner = null; +function releaseExecDisplay() { + if (execDisplayOwner) execDisplayOwner.messages = []; + execDisplayOwner = null; + currentExecSessionId = null; + if (tailIntervalId) clearInterval(tailIntervalId); + tailIntervalId = null; + const tail = document.getElementById('nohup-tail-log'); + const stop = document.getElementById('nohup-stop-tail'); + if (tail) tail.style.display = 'inline-block'; + if (stop) stop.style.display = 'none'; +} +function claimExecDisplay(kind, element) { + releaseExecDisplay(); + const owner = {kind, element, sessionId: null, finished: false, messages: [], bytes: 0}; + execDisplayOwner = owner; + delete element.dataset.serviceCommand; + delete element.dataset.operationId; + delete element.dataset.operationPhase; + const matchPanel = document.getElementById('match-result-panel'); + if (matchPanel) matchPanel.style.display = 'none'; + return owner; +} +function ownsExecDisplay(owner) { + return owner && execDisplayOwner === owner && document.getElementById('exec-result') === owner.element; +} +function acknowledgeExecSession(owner, sessionId) { + if (!ownsExecDisplay(owner)) return; + owner.sessionId = sessionId; + currentExecSessionId = sessionId; + const buffered = owner.messages; + owner.messages = []; + owner.bytes = 0; + if (owner.overflow) owner.element.textContent += '\n' + runtimeText('outcomeUnknown') + '\n'; + for (const message of buffered) handleSshExecMessage(message); +} + /* nohup 相关状态(用于快捷按钮) */ let currentNohupInfo = { logFile: null, @@ -9150,18 +9525,23 @@ async function nohupTailLog() { const tailBtn = document.getElementById('nohup-tail-log'); const stopBtn = document.getElementById('nohup-stop-tail'); const resultPre = document.getElementById('exec-result'); + const owner = claimExecDisplay('tail', resultPre); + const info = {...currentNohupInfo}; + let fetching = false; // 切换按钮状态 tailBtn.style.display = 'none'; stopBtn.style.display = ''; - resultPre.textContent += `\n\n━━━━━━━━━━━━━━━━━━━━━━\n${typeof t === 'function' ? t('sshPage.startRealTimeTail', { logFile: currentNohupInfo.logFile }) : `开始实时跟踪: ${currentNohupInfo.logFile}\n(点击"停止跟踪"按钮退出)`}\n━━━━━━━━━━━━━━━━━━━━━━\n`; + resultPre.textContent += `\n\n━━━━━━━━━━━━━━━━━━━━━━\n${typeof t === 'function' ? t('sshPage.startRealTimeTail', { logFile: currentNohupInfo.logFile }) : `开始实时跟踪: ${info.logFile}\n(点击"停止跟踪"按钮退出)`}\n━━━━━━━━━━━━━━━━━━━━━━\n`; lastTailContent = ''; // 定时获取日志 const fetchLog = async () => { + if (!ownsExecDisplay(owner) || fetching || document.hidden) return; + fetching = true; try { - const host = window._cmdHostsList?.find(h => h.id === currentNohupInfo.hostId); + const host = window._cmdHostsList?.find(h => h.id === info.hostId); if (!host) return; const result = await api.call('ssh.exec', { @@ -9169,10 +9549,11 @@ async function nohupTailLog() { port: host.port, user: host.username, keyid: host.keyid, - command: `tail -100 "${currentNohupInfo.logFile}" 2>/dev/null`, + command: `tail -100 "${info.logFile}" 2>/dev/null`, timeout_ms: 5000 }); + if (!ownsExecDisplay(owner)) return; const content = result.data?.stdout || ''; // 只显示新增内容 if (content && content !== lastTailContent) { @@ -9188,14 +9569,14 @@ async function nohupTailLog() { resultPre.scrollTop = resultPre.scrollHeight; } } catch (e) { - console.error('Tail log error:', e); - } + if (ownsExecDisplay(owner)) console.error('Tail log error:', e); + } finally { fetching = false; } }; // 立即获取一次 await fetchLog(); // 每2秒获取一次 - tailIntervalId = setInterval(fetchLog, 2000); + if (ownsExecDisplay(owner)) tailIntervalId = setInterval(fetchLog, 2000); } /* nohup 快捷操作:停止实时跟踪 */ @@ -9212,12 +9593,20 @@ function nohupStopTail() { if (tailBtn) tailBtn.style.display = ''; if (stopBtn) stopBtn.style.display = 'none'; + if (!resultPre || execDisplayOwner?.kind !== 'tail') return; + releaseExecDisplay(); resultPre.textContent += t('promptRepair.tailStopped'); resultPre.scrollTop = resultPre.scrollHeight; } /* nohup 快捷操作:检查进程(使用 PID 文件) */ async function nohupCheckProcess() { + if (currentNohupInfo.commandId) { + const owner = execDisplayOwner; + const state = await verifyServiceState(currentNohupInfo.commandId); + if (state && ownsExecDisplay(owner)) owner.element.textContent += '\n' + runtimeText(state.state || 'unknown'); + return; + } if (!currentNohupInfo.pidFile || !currentNohupInfo.hostId) { showToast((typeof t === 'function' ? t('toast.noProcessInfo') : '没有可用的进程信息'), 'warning'); return; @@ -9228,6 +9617,10 @@ async function nohupCheckProcess() { /* nohup 快捷操作:停止进程(使用 PID 文件) */ async function nohupStopProcess() { + if (currentNohupInfo.commandId) { + await quickActionStopProcess(currentNohupInfo.commandId, false); + return; + } if (!currentNohupInfo.pidFile || !currentNohupInfo.hostId) { showToast((typeof t === 'function' ? t('toast.noProcessInfo') : '没有可用的进程信息'), 'warning'); return; @@ -9238,25 +9631,28 @@ async function nohupStopProcess() { return; } + const info = {...currentNohupInfo}; // 停止实时跟踪(如果正在进行) nohupStopTail(); // 使用 PID 文件精确停止 - await executeNohupHelperCommand(`if [ -f ${currentNohupInfo.pidFile} ]; then kill $(cat ${currentNohupInfo.pidFile}) 2>/dev/null && rm -f ${currentNohupInfo.pidFile} && echo "进程已停止"; else echo "PID 文件不存在"; fi`); + const owner = await executeNohupHelperCommand(`if [ -f ${info.pidFile} ]; then kill $(cat ${info.pidFile}) 2>/dev/null && rm -f ${info.pidFile} && echo "进程已停止"; else echo "PID 文件不存在"; fi`, info); + if (!ownsExecDisplay(owner)) return; // 再次检查进程状态 - await executeNohupHelperCommand(`[ -f ${currentNohupInfo.pidFile} ] && kill -0 $(cat ${currentNohupInfo.pidFile}) 2>/dev/null && echo "进程仍在运行" || echo "确认:进程已停止"`); + await executeNohupHelperCommand(`[ -f ${info.pidFile} ] && kill -0 $(cat ${info.pidFile}) 2>/dev/null && echo "进程仍在运行" || echo "确认:进程已停止"`, info); } /* 执行 nohup 辅助命令 */ -async function executeNohupHelperCommand(command) { - const host = window._cmdHostsList?.find(h => h.id === currentNohupInfo.hostId); +async function executeNohupHelperCommand(command, info = currentNohupInfo) { + const host = window._cmdHostsList?.find(h => h.id === info.hostId); if (!host) { showToast((typeof t === 'function' ? t('sshPage.hostNotFound') : '主机信息不存在'), 'error'); return; } const resultPre = document.getElementById('exec-result'); + const owner = claimExecDisplay('log', resultPre); resultPre.textContent += `\n\n━━━━━━━━━━━━━━━━━━━━━━\n$ ${command}\n`; try { @@ -9270,6 +9666,7 @@ async function executeNohupHelperCommand(command) { }); // ssh.exec 返回 stdout 和 stderr,不是 output + if (!ownsExecDisplay(owner)) return; const stdout = result.data?.stdout || ''; const stderr = result.data?.stderr || ''; if (stdout || stderr) { @@ -9279,11 +9676,13 @@ async function executeNohupHelperCommand(command) { resultPre.textContent += t('promptRepair.noOutput'); } } catch (e) { + if (!ownsExecDisplay(owner)) return; resultPre.textContent += t('promptRepair.executionFailed', {message: e.message}); } // 滚动到底部 resultPre.scrollTop = resultPre.scrollHeight; + return owner; } /** @@ -9309,6 +9708,7 @@ async function viewServiceLog(idx, safeName) { // 显示结果区域 const resultSection = document.getElementById('exec-result-section'); const resultPre = document.getElementById('exec-result'); + const owner = claimExecDisplay('log', resultPre); resultSection.style.display = ''; document.getElementById('cancel-exec-btn').style.display = 'none'; document.getElementById('nohup-actions').style.display = 'none'; @@ -9326,6 +9726,7 @@ async function viewServiceLog(idx, safeName) { timeout_ms: 10000 }); + if (!ownsExecDisplay(owner)) return; const stdout = result.data?.stdout || ''; const stderr = result.data?.stderr || ''; @@ -9337,6 +9738,7 @@ async function viewServiceLog(idx, safeName) { resultPre.textContent += (typeof t === 'function' ? t('ui.logEmpty') : '(日志为空)'); } } catch (e) { + if (!ownsExecDisplay(owner)) return; resultPre.textContent += typeof t === 'function' ? t('sshPage.getLogFailedMsg', { msg: e.message }) : `获取日志失败: ${e.message}`; } @@ -9348,111 +9750,38 @@ async function viewServiceLog(idx, safeName) { * @param {number} idx - 命令索引 * @param {string} safeName - 安全名称(用于 PID 文件) */ -async function stopServiceProcess(idx, safeName) { +async function stopServiceProcess(idx) { const cmd = sshCommands[selectedHostId]?.[idx]; - if (!cmd) { - showToast((typeof t === 'function' ? t('sshPage.cmdNotFound') : '命令不存在'), 'error'); - return; - } - - const host = window._cmdHostsList?.find(h => h.id === selectedHostId); - if (!host) { - showToast((typeof t === 'function' ? t('sshPage.hostNotFound') : '主机信息不存在'), 'error'); - return; - } - - // 确认对话框 - if (!await confirmAction(typeof t === 'function' ? t('ui.confirmStopService', { name: cmd.name }) : `确定要停止服务 "${cmd.name}" 吗?`, { primary: t('common.stop'), tone: 'neutral' })) { - return; - } - - const pidFile = `/tmp/ts_nohup_${safeName}.pid`; - - // 显示结果区域 - const resultSection = document.getElementById('exec-result-section'); - const resultPre = document.getElementById('exec-result'); - resultSection.style.display = ''; - document.getElementById('cancel-exec-btn').style.display = 'none'; - document.getElementById('nohup-actions').style.display = 'none'; - - resultPre.textContent = (typeof t === 'function' ? t('sshPage.stopServiceName', { name: cmd.name }) : `停止服务: ${cmd.name}`) + '\n\n'; - resultSection.scrollIntoView({ behavior: 'smooth' }); - + if (cmd) await quickActionStopProcess(cmd.id, false); +} + +const serviceStartInFlight = new Set(); +async function executeManagedService(cmd, resultPre) { + if (serviceStartInFlight.has(cmd.id)) return; + serviceStartInFlight.add(cmd.id); + const owner = claimExecDisplay('service', resultPre); + advanceServiceState(cmd.id); + const safeName = cmd.name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || String(cmd.id).replace(/[^a-zA-Z0-9]/g, '').slice(0, 20) || 'cmd'; + currentNohupInfo = {commandId: cmd.id, hostId: selectedHostId, logFile: `/tmp/ts_nohup_${safeName}.log`}; + resultPre.dataset.serviceCommand = cmd.id; + delete resultPre.dataset.operationId; + delete resultPre.dataset.operationPhase; + resultPre.textContent = cmd.name + '\n' + runtimeText('starting') + '\n'; try { - // 先检查进程状态 - const checkResult = await api.call('ssh.exec', { - host: host.host, - port: host.port, - user: host.username, - keyid: host.keyid, - command: `if [ -f ${pidFile} ]; then PID=$(cat ${pidFile}); if kill -0 $PID 2>/dev/null; then echo "RUNNING:$PID"; else echo "STOPPED"; fi; else echo "NO_PID"; fi`, - timeout_ms: 5000 - }); - - requireApiSuccess(checkResult, 'ssh.exec'); - const status = (checkResult.data?.stdout || '').trim(); - - if (status.startsWith('RUNNING:')) { - const pid = status.split(':')[1]; - resultPre.textContent += (typeof t === 'function' ? t('sshPage.processRunningPid', { pid }) : `进程运行中 (PID: ${pid}),正在停止...`) + '\n'; - - // 发送 SIGTERM - const killResult = await api.call('ssh.exec', { - host: host.host, - port: host.port, - user: host.username, - keyid: host.keyid, - command: `kill ${pid} 2>/dev/null && sleep 0.5 && (kill -0 ${pid} 2>/dev/null && echo "STILL_RUNNING" || echo "STOPPED")`, - timeout_ms: 10000 - }); - - requireApiSuccess(killResult, 'ssh.exec'); - const killStatus = (killResult.data?.stdout || '').trim(); - if (killStatus === 'STOPPED') { - resultPre.textContent += t('promptRepair.serviceStoppedOutput'); - showToast((typeof t === 'function' ? t('toast.serviceStopped') : '服务已停止'), 'success'); - - // 更新状态变量 - if (cmd.varName) { - try { - requireApiSuccess(await api.call('automation.variables.set', { name: `${cmd.varName}.status`, value: 'stopped' }), 'call'); - } catch (e) {} - } - - // 刷新命令列表状态 - updateServiceStatusInList(); - } else if (killStatus === 'STILL_RUNNING') { - resultPre.textContent += t('promptRepair.sshStopping') + '\n'; - // 发送 SIGKILL - const forced = await api.call('ssh.exec', { - host: host.host, - port: host.port, - user: host.username, - keyid: host.keyid, - command: `kill -9 ${pid} 2>/dev/null; rm -f ${pidFile}`, - timeout_ms: 5000 - }); - requireApiSuccess(forced, 'ssh.exec'); - resultPre.textContent += t('promptRepair.stopUnconfirmed') + '\n'; - showToast(t('promptRepair.stopUnconfirmed'), 'warning'); - updateServiceStatusInList(); - } else { - resultPre.textContent += t('promptRepair.sshUnknown'); - showToast(t('promptRepair.sshUnknown'), 'warning'); - } - } else if (status === 'STOPPED') { - resultPre.textContent += t('promptRepair.processStoppedOutput'); - showToast((typeof t === 'function' ? t('toast.processAlreadyStopped') : '进程已经停止'), 'info'); - } else { - resultPre.textContent += t('promptRepair.sshUnknown'); - showToast(t('promptRepair.sshUnknown'), 'warning'); - } + const result = requireApiSuccess(await api.call('ssh.services.start', {command_id: cmd.id}, 'POST'), 'ssh.services.start'); + if (!Number.isInteger(result.data?.operation_id) || result.data.operation_id <= 0) + throw new ApiOperationError({}, 'ssh.services.start', {kind: 'format', uncertain: true}); + if (!ownsExecDisplay(owner)) return; + resultPre.dataset.operationId = String(result.data.operation_id); + resultPre.textContent += runtimeText('launch_accepted'); } catch (e) { - resultPre.textContent += t('promptRepair.stopFailed', {message: e.message}); - showToast((typeof t === 'function' ? t('toast.stopServiceFailedMsg', { msg: e.message }) : '停止服务失败: ' + e.message), 'error'); + if (ownsExecDisplay(owner)) + resultPre.textContent += e.uncertain || e.kind === 'timeout' || e.code === 5 ? runtimeText('outcomeUnknown') : e.message; + } finally { + advanceServiceState(cmd.id); + serviceStartInFlight.delete(cmd.id); + await updateServiceStatusInList(); } - - resultPre.scrollTop = resultPre.scrollHeight; } async function executeCommand(idx) { @@ -9488,6 +9817,13 @@ async function executeCommand(idx) { nohupActions.style.display = 'none'; } + if (cmd.nohup && cmd.serviceMode) { + await executeManagedService(cmd, resultPre); + return; + } + + const owner = claimExecDisplay('ssh', resultPre); + // 对于 nohup 命令,包装命令以实现后台执行,并记录日志和 PID let actualCommand = cmd.command; let nohupLogFile = null; @@ -9557,7 +9893,7 @@ async function executeCommand(idx) { const result = requireApiSuccess(await api.call('ssh.exec_stream', params), 'ssh.exec_stream'); if (!Number.isInteger(result.data?.session_id)) throw new ApiOperationError({}, 'ssh.exec_stream', {kind: 'format', uncertain: true}); - currentExecSessionId = result.data?.session_id; + if (!ownsExecDisplay(owner)) return; if (cmd.nohup) { resultPre.textContent += t('promptRepair.sshAccepted'); @@ -9565,14 +9901,19 @@ async function executeCommand(idx) { resultPre.textContent += t('promptRepair.logFileSpaced', {path: nohupLogFile}); resultPre.textContent += t('promptRepair.processKeyword', {keyword: cmd.command.split(' ')[0]}); // nohup 命令不跟踪会话 - currentExecSessionId = null; + // The display still follows this session; it is not a cancellable foreground command; } else { - resultPre.textContent += t('promptRepair.sessionWaiting', {id: currentExecSessionId}); + resultPre.textContent += t('promptRepair.sessionWaiting', {id: result.data.session_id}); } + acknowledgeExecSession(owner, result.data.session_id); + if (cmd.nohup) currentExecSessionId = null; // 输出将通过 WebSocket 实时推送 } catch (e) { + if (!ownsExecDisplay(owner)) return; + owner.finished = true; + owner.messages = []; if (e.uncertain || e.kind === 'timeout' || e.code === 5 || e.code === 'TIMEOUT') { resultPre.textContent += '\n' + t('promptRepair.sshSubmissionUnknown'); showToast(t('promptRepair.sshSubmissionUnknown'), 'warning', 10000); @@ -9592,14 +9933,18 @@ async function cancelExecution() { return; } + const owner = execDisplayOwner; + const sessionId = currentExecSessionId; const cancelBtn = document.getElementById('cancel-exec-btn'); cancelBtn.disabled = true; cancelBtn.textContent = typeof t === 'function' ? t('ui.cancelling') : '取消中...'; try { - requireApiSuccess(await api.call('ssh.cancel', { session_id: currentExecSessionId }), 'call'); + requireApiSuccess(await api.call('ssh.cancel', { session_id: sessionId }), 'call'); + if (!ownsExecDisplay(owner)) return; showToast((typeof t === 'function' ? t('toast.cancelSent') : '取消请求已发送'), 'info'); } catch (e) { + if (!ownsExecDisplay(owner)) return; showToast((typeof t === 'function' ? t('toast.cancelFailedMsg', { msg: e.message }) : '取消失败: ' + e.message), 'error'); cancelBtn.disabled = false; cancelBtn.innerHTML = ' ' + (typeof t === 'function' ? t('sshPage.cancelEsc') : '取消 (Esc)'); @@ -9630,7 +9975,18 @@ function handleSshExecMessage(msg) { const cancelBtn = document.getElementById('cancel-exec-btn'); const matchPanel = document.getElementById('match-result-panel'); - if (!resultPre) return; + const owner = execDisplayOwner; + if (!ownsExecDisplay(owner) || owner.kind !== 'ssh' || owner.finished) return; + if (owner.sessionId === null) { + const bytes = JSON.stringify(msg).length; + if (owner.messages.length < 64 && owner.bytes + bytes <= 65536) { + owner.messages.push(msg); + owner.bytes += bytes; + } else owner.overflow = true; + return; + } + if (msg.session_id !== owner.sessionId) return; + if (['ssh_exec_done', 'ssh_exec_error', 'ssh_exec_cancelled'].includes(msg.type)) owner.finished = true; switch (msg.type) { case 'ssh_exec_start': @@ -9647,8 +10003,7 @@ function handleSshExecMessage(msg) { case 'ssh_exec_output': // 接受消息如果:session_id 匹配,或者我们还没有 session_id(等待 API 返回) - if (msg.session_id === currentExecSessionId || - (currentExecSessionId === null && msg.session_id)) { + if (msg.session_id === owner.sessionId) { // 如果还没有 session_id,从消息中获取 if (currentExecSessionId === null) { currentExecSessionId = msg.session_id; @@ -9667,7 +10022,7 @@ function handleSshExecMessage(msg) { case 'ssh_exec_match': /* 实时匹配结果 */ - if (msg.session_id === currentExecSessionId) { + if (msg.session_id === owner.sessionId) { const isFinal = msg.is_final === true; /* 是否为终止匹配(expect/fail 匹配)*/ const isExtractOnly = !msg.expect_matched && !msg.fail_matched && msg.extracted; @@ -9695,7 +10050,7 @@ function handleSshExecMessage(msg) { break; case 'ssh_exec_done': - if (msg.session_id === currentExecSessionId) { + if (msg.session_id === owner.sessionId) { resultPre.textContent += t('promptRepair.sshEnd'); resultPre.textContent += t('promptRepair.exitCode', {code: msg.exit_code ?? '?'}); @@ -9735,7 +10090,7 @@ function handleSshExecMessage(msg) { break; case 'ssh_exec_error': - if (msg.session_id === currentExecSessionId) { + if (msg.session_id === owner.sessionId) { resultPre.textContent += t('promptRepair.sshError', {message: apiErrorMessage({error: msg.error})}); if (cancelBtn) { cancelBtn.style.display = 'none'; @@ -9746,7 +10101,7 @@ function handleSshExecMessage(msg) { break; case 'ssh_exec_cancelled': - if (msg.session_id === currentExecSessionId) { + if (msg.session_id === owner.sessionId) { resultPre.textContent += t('promptRepair.sshCancelOutput'); if (cancelBtn) { cancelBtn.style.display = 'none'; @@ -9830,6 +10185,7 @@ function updateMatchResultPanel(msg, isExtractOnly = false) { } function clearExecResult() { + releaseExecDisplay(); document.getElementById('exec-result-section').style.display = 'none'; document.getElementById('exec-result').textContent = ''; document.getElementById('cancel-exec-btn').style.display = 'none'; @@ -10183,8 +10539,9 @@ async function loadSecurityPage() { row(t('securityPage.keysTableComment'), inp('keygen-comment', 200, t('securityPage.commentPlaceholder'))) + row(t('sshPage.keyAlias'), inp('keygen-alias', 200, t('securityPage.aliasPlaceholder')), '', t('securityPage.aliasHint')) + row(t('securityPage.keysTableExportable'), swc('keygen-exportable'), '', t('securityPage.allowExportPrivateKey')) + - row(t('securityPage.hideShort'), swc('keygen-hidden'), '', t('securityPage.hideKeyIdHint'))), - ``)}
+ row(t('securityPage.hideShort'), swc('keygen-hidden'), '', t('securityPage.hideKeyIdHint'))) + + `
`, + ``)}
`; + fillConditionEditor(row, 'action', condition); container.appendChild(row); actionRowCount++; } @@ -18023,34 +18557,18 @@ async function submitAddRule(originalId = null) { return; } - // 收集条件(仅手动触发时为空数组) + // Dormant trigger conditions are preserved when manual-only is selected. const conditions = []; - { - document.querySelectorAll('.condition-row').forEach(row => { - const variable = row.querySelector('.cond-variable').value.trim(); - const operator = row.querySelector('.cond-operator').value; - let value = row.querySelector('.cond-value').value.trim(); - - if (variable) { - // 尝试解析值为 JSON - try { - value = JSON.parse(value); - } catch (e) { - // 保持字符串 - } - - const condition = { variable, operator, value }; - if (row._originalCondition && Object.is(value, row._originalCondition.value) && row._originalCondition.value_type !== undefined) - condition.value_type = row._originalCondition.value_type; - conditions.push(condition); - } - }); + for (const row of document.querySelectorAll('#add-rule-modal .condition-row')) { + const condition = readConditionEditor(row, 'trigger'); + if (!condition) return; + conditions.push(condition); } - + // 收集动作模板引用(包含 template_id、delay_ms、重复选项和动作条件) const actions = []; - document.querySelectorAll('#add-rule-modal .action-row').forEach(row => { - if (row.dataset.inline === 'true') { actions.push(structuredClone(row._originalAction)); return; } + for (const row of document.querySelectorAll('#add-rule-modal .action-row')) { + if (row.dataset.inline === 'true') { actions.push(structuredClone(row._originalAction)); continue; } const templateId = row.querySelector('.action-template-id')?.value; const delay_ms = parseInt(row.querySelector('.action-delay')?.value) || 0; const repeat_mode = row.querySelector('.action-repeat-mode')?.value || 'once'; @@ -18059,9 +18577,7 @@ async function submitAddRule(originalId = null) { // 收集动作条件 const hasCondition = row.querySelector('.action-has-condition')?.checked; - const condVariable = row.querySelector('.action-condition-variable')?.value?.trim(); - const condOperator = row.querySelector('.action-condition-operator')?.value; - const condValueRaw = row.querySelector('.action-condition-value')?.value?.trim(); + if (hasCondition && !templateId) { fieldError('actions-container', t('ui.alertSelectAction')); return; } if (templateId) { const actionRef = row._originalAction?.template_id === templateId ? structuredClone(row._originalAction) : {}; @@ -18081,29 +18597,15 @@ async function submitAddRule(originalId = null) { } } - // 添加动作条件 - if (hasCondition && condVariable) { - // 尝试解析条件值 - let condValue = condValueRaw; - try { - condValue = JSON.parse(condValueRaw); - } catch (e) { - // 保持字符串 - } - - actionRef.condition = { - variable: condVariable, - operator: condOperator, - value: condValue - }; - const oldCondition = row._originalAction?.condition; - if (oldCondition && Object.is(oldCondition.value, condValue) && oldCondition.value_type !== undefined) - actionRef.condition.value_type = oldCondition.value_type; + if (hasCondition) { + const condition = readConditionEditor(row, 'action'); + if (!condition) return; + actionRef.condition = condition; } - + actions.push(actionRef); } - }); + } if (actions.length === 0) { fieldError('actions-container', t('ui.alertSelectAction')); @@ -18237,8 +18739,9 @@ function showExportSourceModal(sourceId) { document.body.appendChild(modal); } - modal.innerHTML = exportSheet('source', t('automation.exportSourceTitle'), t('automation.exportSourceDesc', {id: escapeHtml(sourceId)}), t('securityPage.targetCertHint'), 'hideExportSourceModal', `doExportSource('${escapeHtml(sourceId)}')`); + modal.innerHTML = exportSheet('source', t('automation.exportSourceTitle'), t('automation.exportSourceDesc', {id: escapeHtml(sourceId)}), t('securityPage.targetCertHint'), 'hideExportSourceModal', 'doExportSource'); + modal.dataset.exportId = sourceId; modal.classList.remove('hidden'); } @@ -18422,8 +18925,9 @@ function showExportRuleModal(ruleId) { document.body.appendChild(modal); } - modal.innerHTML = exportSheet('rule', t('ruleConfig.exportTitle'), t('ruleConfig.exportDesc', {id: escapeHtml(ruleId)}), t('ruleConfig.certHint'), 'hideExportRuleModal', `doExportRule('${escapeHtml(ruleId)}')`); + modal.innerHTML = exportSheet('rule', t('ruleConfig.exportTitle'), t('ruleConfig.exportDesc', {id: escapeHtml(ruleId)}), t('ruleConfig.certHint'), 'hideExportRuleModal', 'doExportRule'); + modal.dataset.exportId = ruleId; modal.classList.remove('hidden'); } @@ -18607,8 +19111,9 @@ function showExportActionModal(actionId) { document.body.appendChild(modal); } - modal.innerHTML = exportSheet('action', t('automation.exportActionTitle'), t('automation.exportActionDesc', {actionId: escapeHtml(actionId)}), t('securityPage.targetCertHint'), 'hideExportActionModal', `doExportAction('${escapeHtml(actionId)}')`); + modal.innerHTML = exportSheet('action', t('automation.exportActionTitle'), t('automation.exportActionDesc', {actionId: escapeHtml(actionId)}), t('securityPage.targetCertHint'), 'hideExportActionModal', 'doExportAction'); + modal.dataset.exportId = actionId; modal.classList.remove('hidden'); } diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index b4d62427..f8e20e85 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -2,7 +2,18 @@ * TianShanOS WebUI - English Language Pack */ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { + conditionEditor: { + variableRequired: "Select a condition variable.", + variableInvalid: "The condition variable is invalid or exceeds 63 UTF-8 bytes.", + unsupportedOperator: "Operator {operator} is unsupported. Select a supported comparison.", + numberInvalid: "The comparison value must be a finite number.", + stringNul: "The comparison value cannot contain a null character (\\u0000).", + stringTooLong: "The comparison string exceeds 63 UTF-8 bytes.", + scalarRequired: "Comparison values support strings, numbers, booleans or null, not arrays or objects.", + typeInvalid: "The comparison value conflicts with its original type. Check the input.", + }, promptRepair: { + serviceDeleteProtected: 'The bound service is not confirmed stopped, or an operation is using it. Verify or stop the service and wait for operations to finish before deleting this configuration.', taskResults: 'Configuration task history', taskAttempt: 'Attempt {attempt}: {path}', retry: 'Retry', @@ -254,6 +265,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { "failed": "Failure log matched", "timeout": "Readiness check timed out", "lastKnown": "Last known", + "queued": "Queued for launch", + "executing": "Starting", + "controlQueued": "Operation queued", + "controlVerifying": "Verifying remote state", + "controlBusy": "Service control is busy or the current state does not permit this operation.", + "controlExpired": "The operation expired in the queue; no remote action was executed.", + "launch_accepted": "Launch accepted. Service status will show the outcome.", + "launch_queued": "Launch is queued.", + "launch_executing": "Launch is executing.", + "launch_succeeded": "Process launch confirmed; check service state for readiness.", + "launch_failed": "Launch failed.", + "launch_unconfirmed": "Launch outcome unconfirmed. Verify remote state before retrying.", + "launch_operationReplaced": "A later operation has replaced this launch record.", + "verifyState": "Verify remote state", + "launchConfirmed": "Process launch confirmed. Check service status for model readiness.", "confirmedAt": "Last confirmation (uptime)", "stateEvidence": "Evidence source", "startBlocked": "Start unavailable: check enablement, manual permission and service state.", @@ -614,6 +640,14 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { status: 'Fan Status', speed: 'Speed', targetSpeed: 'Target Speed', + currentOutput: 'Current output', + draftSetting: 'Ready to set {speed}%', + requestedSetting: 'Requested setting: {speed}%', + outputUnknown: 'Current output unconfirmed', + adjustmentFailed: 'Fan adjustment failed', + settingFailed: 'Could not set fan {id} to {speed}%.', + settingUnknown: 'Unable to confirm whether the setting succeeded. Check the current output before making another change.', + settingDisabled: 'Fan {id} setting updated. The fan is currently disabled.', actualSpeed: 'Actual Speed', temperature: 'Temperature', effectiveTemp: 'Effective Temp', @@ -639,7 +673,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { enterValidTemp: 'Please enter a valid temperature (0-100°C)', testTempCleared: 'Test temperature cleared, resumed normal mode', testTempSet: 'Test temperature set to {temp}°C', - speedSet: 'Fan {id} speed set to {speed}%', + speedSet: 'Fan {id} set to {speed}%.', modeSwitch: 'Fan {id} mode switched to {mode}', setFanFailed: 'Failed to set fan: {msg}', setFanModeFailed: 'Failed to set fan mode: {msg}', @@ -1918,6 +1952,25 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { dhcpServer: 'DHCP Server', natGateway: 'NAT Gateway', lpmuAccessTitle: 'Upstream Network Access', + lpmuAccessSudoPassword: 'LPMU sudo password', + lpmuAccessPasswordHint: 'Enter the sudo password for the configured SSH account on LPMU. It is used for this attempt only and is not saved.', + lpmuAccessPasswordRequired: 'Enter the LPMU sudo password', + lpmuAccessUnconfirmed: 'The script exited without confirming Internet connectivity and configuration completion. Check the script output and network cabling.', + lpmuAccessResultDescription: 'LPMU reported a working Internet connection and completed network configuration.', + lpmuAccessInternet: 'Internet connection', + lpmuAccessInternetConfirmed: 'Connected', + lpmuAccessConfiguration: 'Network configuration', + lpmuAccessConfigurationDone: 'Complete', + lpmuAccessResultDone: 'Done', + lpmuAccessLog: 'Execution log', + lpmuAccessLogEmpty: 'No output was recorded for this attempt.', + lpmuAccessLogFailed: 'Could not load the execution log', + lpmuAccessLogInvalidPage: 'Invalid log page or no progress while reading', + lpmuAccessLastOutput: 'Last output: {output}', + lpmuAccessNoInternet: 'No usable internet connection was found.', + lpmuAccessPasswordIncorrect: 'Incorrect sudo password. Please enter it again.', + lpmuAccessTechnicalDetail: 'Execution status: {error}', + lpmuAccessExitCode: 'Script exit code: {code}', lpmuAccessButton: 'Access via LPMU', lpmuAccessIdle: 'Not started', lpmuAccessRunning: 'Processing', @@ -1931,6 +1984,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lpmuAccessOutput: 'Output', lpmuAccessStartFailed: 'Failed to start access', lpmuAccessStatusFailed: 'Failed to fetch status', + lpmuAccessStatusUnknown: 'Status unavailable; execution outcome is not yet confirmed', + lpmuAccessRefreshStatus: 'Refresh status', clients: 'Clients', // Panel titles wifiNetworks: 'WiFi Networks', @@ -2332,6 +2387,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, // Security Page + keyGeneration: { + "running": "Generating key “{id}”. Closing this window does not cancel generation on the device.", + "closeRunning": "Close window", + "invalidId": "Key IDs allow at most 10 UTF-8 bytes and cannot contain a comma.", + "storageFull": "Device storage is full. The key was not fully saved.", + "occupied": "This ID is already in use. Choose another ID.", + "limit": "The key count limit has been reached.", + "memory": "Device memory is insufficient. Generation did not complete.", + "stage": "Failed stage: {stage}; error: {error}", + "cleanupFailed": "Cleanup of this operation did not complete: {error}. Check storage before proceeding.", + "unknown": "Generation has not been confirmed. The device may still be generating. Check the record before proceeding; do not repeat generation with the same ID.", + "recordFound": "A record with this ID exists, but its ownership by this request and completeness of key material are not confirmed. Check the record.", + "refreshFailed": "The key was generated, but the list could not be refreshed. Refresh manually to check." +}, + securityPage: { previewRowLabel: 'Config content', previewAfterSelect: 'Preview appears after selecting a file', @@ -3249,6 +3319,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, // Data Monitoring Widgets + inputRepair: {"invalidNumber": "Enter a valid number within the allowed range.", "minMax": "Maximum must be greater than minimum.", "immutableId": "The ID cannot be changed while editing.", "csrIdLimit": "Device ID must fit within 63 UTF-8 bytes.", "defaultFilter": "This filter keeps its existing default settings. Adjust a parameter to save custom settings.", "defaultParam": "Default"}, + dataWidget: { addWidget: 'Add widget', editWidget: 'Edit widget', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 8b677f83..66631573 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -2,7 +2,18 @@ * TianShanOS WebUI - 简体中文语言包 */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { + conditionEditor: { + variableRequired: "请选择条件变量。", + variableInvalid: "条件变量名称无效或过长(最多 63 个 UTF-8 字节)。", + unsupportedOperator: "当前不支持操作符 {operator},请选择支持的比较方式。", + numberInvalid: "比较值必须是有限数字。", + stringNul: "比较值不能包含空字符(\\u0000)。", + stringTooLong: "比较字符串过长(最多 63 个 UTF-8 字节)。", + scalarRequired: "比较值仅支持字符串、数字、布尔值或 null,不支持数组和对象。", + typeInvalid: "比较值与原始类型不一致,请检查输入。", + }, promptRepair: { + serviceDeleteProtected: '该配置绑定的服务尚未确认停止,或有操作正在使用它。请先核验或停止服务,待操作完成后再删除。', taskResults: '配置应用任务记录', taskAttempt: '第 {attempt} 次:{path}', retry: '重试', @@ -254,6 +265,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { "failed": "匹配到失败日志", "timeout": "就绪检查超时", "lastKnown": "上次状态", + "queued": "等待启动", + "executing": "正在启动", + "controlQueued": "操作排队中", + "controlVerifying": "正在核验远端状态", + "controlBusy": "服务控制正忙或当前状态不允许操作。", + "controlExpired": "操作排队已超时,未执行远端操作。", + "launch_accepted": "启动请求已受理,可通过服务状态查看执行结果。", + "launch_queued": "启动操作正在排队。", + "launch_executing": "正在执行启动操作。", + "launch_succeeded": "进程启动已确认;模型是否就绪请查看服务状态。", + "launch_failed": "启动操作失败。", + "launch_unconfirmed": "启动结果未确认,请核验远端状态后再操作。", + "launch_operationReplaced": "此启动记录已被后续操作替代。", + "verifyState": "核验远端状态", + "launchConfirmed": "进程启动已确认,模型是否就绪请查看服务状态。", "confirmedAt": "上次确认(开机后)", "stateEvidence": "状态依据", "startBlocked": "当前不允许启动:请检查启用、手动权限及服务状态。", @@ -614,6 +640,14 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { status: '风扇状态', speed: '转速', targetSpeed: '目标转速', + currentOutput: '当前输出', + draftSetting: '准备设置为 {speed}%', + requestedSetting: '请求设置:{speed}%', + outputUnknown: '当前输出尚未确认', + adjustmentFailed: '未能完成风扇调节', + settingFailed: '风扇{id}未能改为{speed}%。', + settingUnknown: '暂时无法确认是否设置成功。请先查看当前输出。', + settingDisabled: '风扇{id}设置已更新,风扇当前处于停用状态。', actualSpeed: '实际转速', temperature: '温度', effectiveTemp: '有效温度', @@ -639,7 +673,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { enterValidTemp: '请输入有效温度 (0-100°C)', testTempCleared: '测试温度已清除,恢复正常模式', testTempSet: '测试温度已设置为 {temp}°C', - speedSet: '风扇 {id} 速度已设置为 {speed}%', + speedSet: '风扇{id}已设为{speed}%。', modeSwitch: '风扇 {id} 模式已切换为 {mode}', setFanFailed: '设置风扇失败: {msg}', setFanModeFailed: '设置风扇模式失败: {msg}', @@ -1878,6 +1912,25 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { dhcpServer: 'DHCP 服务器', natGateway: 'NAT 网关', lpmuAccessTitle: '接入上层网络', + lpmuAccessSudoPassword: 'LPMU sudo 密码', + lpmuAccessPasswordHint: '请输入 LPMU 上已配置 SSH 账号的 sudo 密码,仅用于本次接入,不保存。', + lpmuAccessPasswordRequired: '请输入 LPMU sudo 密码', + lpmuAccessUnconfirmed: '脚本已结束,但未收到互联网连接正常和配置完成的确认,请检查脚本输出与网络接线。', + lpmuAccessResultDescription: 'LPMU 已报告互联网连接正常,网络配置已完成。', + lpmuAccessInternet: '互联网连接', + lpmuAccessInternetConfirmed: '连接正常', + lpmuAccessConfiguration: '网络配置', + lpmuAccessConfigurationDone: '已完成', + lpmuAccessResultDone: '完成', + lpmuAccessLog: '执行日志', + lpmuAccessLogEmpty: '本次执行没有输出日志。', + lpmuAccessLogFailed: '执行日志读取失败', + lpmuAccessLogInvalidPage: '日志分页数据无效或读取没有进展', + lpmuAccessLastOutput: '末尾输出:{output}', + lpmuAccessNoInternet: '没有找到可用的互联网连接', + lpmuAccessPasswordIncorrect: 'sudo 密码不正确,请重新输入。', + lpmuAccessTechnicalDetail: '执行状态:{error}', + lpmuAccessExitCode: '脚本退出码:{code}', lpmuAccessButton: '通过LPMU接入', lpmuAccessIdle: '未启动', lpmuAccessRunning: '处理中', @@ -1891,6 +1944,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lpmuAccessOutput: '输出', lpmuAccessStartFailed: '启动接入失败', lpmuAccessStatusFailed: '状态获取失败', + lpmuAccessStatusUnknown: '状态获取失败,执行结果尚未确认', + lpmuAccessRefreshStatus: '刷新状态', clients: '客户端', // 面板标题 wifiNetworks: 'WiFi 网络', @@ -2292,6 +2347,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, // 安全页面 + keyGeneration: { + "running": "正在生成密钥“{id}”。关闭窗口不会取消设备上的生成操作。", + "closeRunning": "关闭窗口", + "invalidId": "密钥 ID 最多 10 个 UTF-8 字节,且不能包含逗号。", + "storageFull": "设备存储空间不足,密钥保存未完成。", + "occupied": "这个 ID 已被使用,请选择其他 ID。", + "limit": "已达到密钥数量上限。", + "memory": "设备内存不足,生成未完成。", + "stage": "失败阶段:{stage};错误:{error}", + "cleanupFailed": "本次数据清理未完成:{error}。请先检查存储状态。", + "unknown": "尚未确认生成结果。设备可能仍在生成;请核对记录后再操作,不要重复生成同一 ID。", + "recordFound": "已查到同 ID 的记录,但尚未确认它属于本次生成,也未确认密钥材料完整。请核对记录。", + "refreshFailed": "密钥已生成,但列表刷新失败,请手动刷新核对。" +}, + securityPage: { previewRowLabel: '配置内容', previewAfterSelect: '选择文件后显示预览', @@ -3211,6 +3281,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, // 数据监控组件 + inputRepair: {"invalidNumber": "请输入范围内的有效数值。", "minMax": "最大值必须大于最小值。", "immutableId": "编辑时不能更改 ID。", "csrIdLimit": "设备 ID 不能超过 63 个 UTF-8 字节。", "defaultFilter": "这项滤镜沿用原有默认设置;调整参数后,会保存自定义设置。", "defaultParam": "默认"}, + dataWidget: { addWidget: '添加组件', editWidget: '编辑组件', diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 582b2259..317c6b51 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -10,7 +10,7 @@ ### 原始项目 **项目名称:** robOS -**GitHub仓库:** https://github.com/thomas-hiddenpeak/robOS +**GitHub仓库:** https://github.com/RMinte-AI/TianshanOS
**项目定位:** RM-01 板上机架操作系统 **开发平台:** ESP32S3 **开发框架:** ESP-IDF v5.5.1 @@ -580,26 +580,26 @@ main ## 📚 技术文档 ### 核心文档 -- [项目进度记录](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/PROJECT_PROGRESS.md) -- [技术架构文档](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/TECHNICAL_ARCHITECTURE.md) -- [API参考文档](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/API_REFERENCE.md) -- [代码规范](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/CODING_STANDARDS.md) +- [项目进度记录](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/PROJECT_PROGRESS.md) +- [技术架构文档](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/TECHNICAL_ARCHITECTURE.md) +- [API参考文档](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/API_REFERENCE.md) +- [代码规范](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/CODING_STANDARDS.md) ### 功能使用指南 -- [温度集成指南](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/TEMPERATURE_INTEGRATION_GUIDE.md) -- [智能安全温度策略](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/SMART_SAFETY_TEMPERATURE_STRATEGY.md) -- [色彩校正系统完整指南](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/COLOR_CORRECTION_GUIDE.md) +- [温度集成指南](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/TEMPERATURE_INTEGRATION_GUIDE.md) +- [智能安全温度策略](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/SMART_SAFETY_TEMPERATURE_STRATEGY.md) +- [色彩校正系统完整指南](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/COLOR_CORRECTION_GUIDE.md) ### LED系统文档 -- [Touch LED组件](https://github.com/thomas-hiddenpeak/robOS/blob/main/components/touch_led/README.md) -- [Matrix LED组件](https://github.com/thomas-hiddenpeak/robOS/blob/main/components/matrix_led/README.md) -- [Board LED组件](https://github.com/thomas-hiddenpeak/robOS/blob/main/components/board_led) -- [色彩校正组件](https://github.com/thomas-hiddenpeak/robOS/blob/main/components/color_correction) +- [Touch LED组件](https://github.com/RMinte-AI/TianshanOS/blob/main/components/touch_led/README.md) +- [Matrix LED组件](https://github.com/RMinte-AI/TianshanOS/blob/main/components/matrix_led/README.md) +- [Board LED组件](https://github.com/RMinte-AI/TianshanOS/blob/main/components/board_led) +- [色彩校正组件](https://github.com/RMinte-AI/TianshanOS/blob/main/components/color_correction) ### 特性开发记录 -- [AGX监控更新](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/AGX_MONITOR_UPDATES.md) -- [AGX启动延迟功能](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/AGX_STARTUP_DELAY_FEATURE.md) -- [绝对静默模式](https://github.com/thomas-hiddenpeak/robOS/blob/main/docs/ABSOLUTE_SILENCE_FINAL.md) +- [AGX监控更新](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/AGX_MONITOR_UPDATES.md) +- [AGX启动延迟功能](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/AGX_STARTUP_DELAY_FEATURE.md) +- [绝对静默模式](https://github.com/RMinte-AI/TianshanOS/blob/main/docs/ABSOLUTE_SILENCE_FINAL.md) --- @@ -709,7 +709,7 @@ main ## 📞 联系信息 **项目团队:** robOS Team -**GitHub仓库:** https://github.com/thomas-hiddenpeak/robOS +**GitHub仓库:** https://github.com/RMinte-AI/TianshanOS
**最新版本:** v1.1.0 **更新日期:** 2025年10月5日 diff --git a/docs/README.md b/docs/README.md index 5b95ed2e..9e078ab2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -109,7 +109,7 @@ ## 🔗 相关资源 ### 外部链接 -- [GitHub 仓库](https://github.com/thomas-hiddenpeak/TianshanOS) +- [GitHub 仓库](https://github.com/RMinte-AI/TianshanOS) - [ESP-IDF 文档](https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/) - [ESP32-S3 技术规格](https://www.espressif.com/en/products/socs/esp32-s3) @@ -160,8 +160,8 @@ ## 📮 反馈与支持 -- **问题反馈**:[GitHub Issues](https://github.com/thomas-hiddenpeak/TianshanOS/issues) -- **功能建议**:[GitHub Discussions](https://github.com/thomas-hiddenpeak/TianshanOS/discussions) +- **问题反馈**:[GitHub Issues](https://github.com/RMinte-AI/TianshanOS/issues) +- **功能建议**:[GitHub Discussions](https://github.com/RMinte-AI/TianshanOS/discussions) - **文档问题**:在对应文档的 Issue 中注明 `[docs]` --- diff --git a/docs/macos27/design-system/boards/SheetsSec.html b/docs/macos27/design-system/boards/SheetsSec.html index 8da65ba5..54c2348b 100644 --- a/docs/macos27/design-system/boards/SheetsSec.html +++ b/docs/macos27/design-system/boards/SheetsSec.html @@ -1 +1 @@ -
生成证书签名请求
设备 ID (CN)
组织
部门
CSR (PEM)
安装证书
安装 CA
查看证书
生成 HTTPS 密钥对
为设备生成 ECDSA P-256 密钥对,用于 mTLS 身份验证。
已存在密钥对,继续将覆盖现有密钥!(仅在已有密钥对时显示)
导出配置包
选择要导出的文件
目录
led/effects.json
led/text.json
配置包信息
名称
描述
接收方设备证书
配置包 (.tscfg)
导入配置包
未选择任何文件
配置包列表
暂无配置包
撤销公钥
主机
用户名
端口
SSH 登录密码
安全警告:主机指纹不匹配!
主机密钥已更改!这可能表明:
• 中间人攻击(Man-in-the-Middle Attack)
• 服务器重新安装或密钥重新生成
• IP 地址被分配给了不同的服务器
主机
192.0.2.30
存储的指纹
SHA256:aB…
当前指纹
SHA256:xY…
建议:如果您确认服务器已重装或密钥已更新,可以点击「更新主机密钥」移除旧记录,然后重新连接以信任新密钥。
导入 SSH 主机配置
选择 .tscfg 配置包文件以导入 SSH 主机配置
未选择任何文件
配置包内容
配置内容
选择文件后显示预览
覆盖已存在的配置
\ No newline at end of file +
生成证书签名请求
设备 ID (CN)
组织
部门
CSR (PEM)
安装证书
安装 CA
查看证书
生成 HTTPS 密钥对
为设备生成 ECDSA P-256 密钥对,用于 mTLS 身份验证。
已存在密钥对,继续将覆盖现有密钥!(仅在已有密钥对时显示)
导出配置包
选择要导出的文件
目录
led/effects.json
led/text.json
配置包信息
名称
描述
接收方设备证书
配置包 (.tscfg)
导入配置包
未选择任何文件
配置包列表
暂无配置包
撤销公钥
主机
用户名
端口
SSH 登录密码
安全警告:主机指纹不匹配!
主机密钥已更改!这可能表明:
• 中间人攻击(Man-in-the-Middle Attack)
• 服务器重新安装或密钥重新生成
• IP 地址被分配给了不同的服务器
主机
192.0.2.30
存储的指纹
SHA256:aB…
当前指纹
SHA256:xY…
建议:如果您确认服务器已重装或密钥已更新,可以点击「更新主机密钥」移除旧记录,然后重新连接以信任新密钥。
导入 SSH 主机配置
选择 .tscfg 配置包文件以导入 SSH 主机配置
未选择任何文件
配置包内容
配置内容
选择文件后显示预览
覆盖已存在的配置
\ No newline at end of file diff --git a/docs/macos27/design-system/source/canvas/SheetsSec.dc.html b/docs/macos27/design-system/source/canvas/SheetsSec.dc.html index 3faf791c..79ba9a08 100644 --- a/docs/macos27/design-system/source/canvas/SheetsSec.dc.html +++ b/docs/macos27/design-system/source/canvas/SheetsSec.dc.html @@ -12,7 +12,7 @@ -
生成证书签名请求
设备 ID (CN)
组织
部门
CSR (PEM)
安装证书
安装 CA
查看证书
生成 HTTPS 密钥对
为设备生成 ECDSA P-256 密钥对,用于 mTLS 身份验证。
已存在密钥对,继续将覆盖现有密钥!(仅在已有密钥对时显示)
导出配置包
选择要导出的文件
目录
led/effects.json
led/text.json
配置包信息
名称
描述
接收方设备证书
配置包 (.tscfg)
导入配置包
未选择任何文件
配置包列表
暂无配置包
撤销公钥
主机
用户名
端口
SSH 登录密码
安全警告:主机指纹不匹配!
主机密钥已更改!这可能表明:
• 中间人攻击(Man-in-the-Middle Attack)
• 服务器重新安装或密钥重新生成
• IP 地址被分配给了不同的服务器
主机
192.0.2.30
存储的指纹
SHA256:aB…
当前指纹
SHA256:xY…
建议:如果您确认服务器已重装或密钥已更新,可以点击「更新主机密钥」移除旧记录,然后重新连接以信任新密钥。
导入 SSH 主机配置
选择 .tscfg 配置包文件以导入 SSH 主机配置
未选择任何文件
配置包内容
配置内容
选择文件后显示预览
覆盖已存在的配置
+
生成证书签名请求
设备 ID (CN)
组织
部门
CSR (PEM)
安装证书
安装 CA
查看证书
生成 HTTPS 密钥对
为设备生成 ECDSA P-256 密钥对,用于 mTLS 身份验证。
已存在密钥对,继续将覆盖现有密钥!(仅在已有密钥对时显示)
导出配置包
选择要导出的文件
目录
led/effects.json
led/text.json
配置包信息
名称
描述
接收方设备证书
配置包 (.tscfg)
导入配置包
未选择任何文件
配置包列表
暂无配置包
撤销公钥
主机
用户名
端口
SSH 登录密码
安全警告:主机指纹不匹配!
主机密钥已更改!这可能表明:
• 中间人攻击(Man-in-the-Middle Attack)
• 服务器重新安装或密钥重新生成
• IP 地址被分配给了不同的服务器
主机
192.0.2.30
存储的指纹
SHA256:aB…
当前指纹
SHA256:xY…
建议:如果您确认服务器已重装或密钥已更新,可以点击「更新主机密钥」移除旧记录,然后重新连接以信任新密钥。
导入 SSH 主机配置
选择 .tscfg 配置包文件以导入 SSH 主机配置
未选择任何文件
配置包内容
配置内容
选择文件后显示预览
覆盖已存在的配置