From 5c92f285edab3b82691e7d808348f32a9cac5513 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 19 May 2026 21:31:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20LPMU=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=B8=8A=E5=B1=82=E7=BD=91=E7=BB=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 21 +- CMakeLists.txt | 4 +- README.md | 10 +- README_EN.md | 8 +- components/ts_api/CMakeLists.txt | 6 + .../assets/lpmu-agx-network-setup.manifest | 6 + .../assets/lpmu-agx-network-setup.tar.gz | Bin 0 -> 12874 bytes components/ts_api/include/ts_api.h | 5 + components/ts_api/src/ts_api.c | 7 + components/ts_api/src/ts_api_lpmu_access.c | 500 ++++++++++++++++++ components/ts_webui/web/css/style.css | 10 + components/ts_webui/web/js/api.js | 2 + components/ts_webui/web/js/app.js | 204 ++++++- components/ts_webui/web/js/lang/en-US.js | 14 + components/ts_webui/web/js/lang/zh-CN.js | 14 + tools/build.sh | 2 +- version.txt | 2 +- 17 files changed, 793 insertions(+), 22 deletions(-) create mode 100644 components/ts_api/assets/lpmu-agx-network-setup.manifest create mode 100644 components/ts_api/assets/lpmu-agx-network-setup.tar.gz create mode 100644 components/ts_api/src/ts_api_lpmu_access.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1de6e2b..574cc09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,12 @@ on: paths-ignore: - '**.md' - 'docs/**' + release: + types: [published] # 手动发布 Release 时也自动构建并上传 bin workflow_dispatch: # 允许手动触发 env: - IDF_VERSION: v5.5 + IDF_VERSION: v5.5.2 TARGET: esp32s3 jobs: @@ -120,11 +122,11 @@ jobs: build/config/sdkconfig.h retention-days: 7 - # 编译成功后自动创建 Release:tag 推送 或 main 分支推送 + # 编译成功后自动创建或更新 Release:tag 推送、main 推送或手动发布 Release release: - name: Create Release + name: Create or Update Release needs: build - if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main' && github.event_name == 'push') + if: github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main' && github.event_name == 'push') runs-on: ubuntu-latest permissions: contents: write # 创建 Release 需要写权限 @@ -149,12 +151,15 @@ jobs: fi - name: Create Release - if: startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && steps.check.outputs.skip != 'true') + if: github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && steps.check.outputs.skip != 'true') uses: softprops/action-gh-release@v2 with: - tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} - name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} - files: firmware/**/*.bin + tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name || startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} + name: ${{ github.event_name == 'release' && github.event.release.tag_name || startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', needs.build.outputs.version) }} + files: | + firmware/**/*.bin + firmware/**/flasher_args.json + fail_on_unmatched_files: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CMakeLists.txt b/CMakeLists.txt index c281d8c..e69d7eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,8 +75,8 @@ set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/components/ts_core/ts_service" ) -# 使用 OTA 分区表(16MB Flash, 双 OTA 分区) -set(PARTITION_TABLE_FILE "${CMAKE_CURRENT_SOURCE_DIR}/partitions_ota.csv" CACHE STRING "" FORCE) +# 使用项目自定义分区表(16MB Flash, 双 OTA 分区 + WebUI 分区) +set(PARTITION_TABLE_FILE "${CMAKE_CURRENT_SOURCE_DIR}/partitions.csv" CACHE STRING "" FORCE) # 包含 ESP-IDF include($ENV{IDF_PATH}/tools/cmake/project.cmake) diff --git a/README.md b/README.md index a4528a6..7d57ded 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ git clone https://github.com/thomas-hiddenpeak/TianshanOS.git cd TianshanOS # 设置 ESP-IDF 环境 -. $HOME/esp/v5.5/esp-idf/export.sh +. $HOME/esp/v5.5.2/esp-idf/export.sh # 设置目标芯片 idf.py set-target esp32s3 @@ -157,8 +157,8 @@ esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \ ## 当前状态 -**版本**: 0.4.5 -**阶段**: Phase 38 完成 - WebUI 多语言支持 +**版本**: 0.4.6 +**阶段**: Phase 39 完成 - LPMU 接入上层网络 ### 已完成功能 @@ -170,13 +170,13 @@ esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \ | 硬件抽象 | GPIO、PWM、I2C、SPI、UART、ADC | | LED 系统 | WS2812 驱动、多设备多图层、特效引擎、BMP/PNG/JPG/GIF | | 控制台 | 命令系统、多语言、脚本引擎、配置持久化 | -| 网络 | WiFi、以太网 W5500、HTTP/HTTPS 服务器 | +| 网络 | WiFi、以太网 W5500、HTTP/HTTPS 服务器、LPMU 接入上层网络 | | 安全 | 会话管理、Token 认证、AES-GCM、RSA/EC、SSH 客户端、PKI 证书管理 | | 驱动 | 风扇控制、电源监控 (ADC/INA3221/PZEM)、AGX/LPMU 电源控制、USB MUX | | WebUI | REST API 网关、WebSocket 广播、前端仪表盘、认证系统、中英文国际化 | | OTA | 双分区升级、版本检测、完整性校验、自动回滚 | | 自动化引擎 | 触发器-条件-动作系统、SSH 远程执行、正则解析、变量系统、电压保护集成 | -| CI/CD | GitHub Actions 自动编译、Tag 触发 Release 发布 | +| CI/CD | GitHub Actions 自动编译、Tag/Release 触发固件发布 | ### 配置包加密系统 (Config Pack) diff --git a/README_EN.md b/README_EN.md index cc97ef2..a5baec2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -93,7 +93,7 @@ git clone https://github.com/thomas-hiddenpeak/TianshanOS.git cd TianshanOS # Set up ESP-IDF environment -. $HOME/esp/v5.5/esp-idf/export.sh +. $HOME/esp/v5.5.2/esp-idf/export.sh # Set target chip idf.py set-target esp32s3 @@ -139,8 +139,8 @@ For detailed instructions, please refer to the [Quick Start Guide](docs/QUICK_ST ## Current Status -**Version**: 0.3.0 -**Phase**: Phase 20 Complete - Automation Engine, SSH Remote Execution, Variable System +**Version**: 0.4.6 +**Phase**: Phase 39 Complete - LPMU Upstream Network Access ### Completed Features @@ -151,7 +151,7 @@ For detailed instructions, please refer to the [Quick Start Guide](docs/QUICK_ST | Hardware Abstraction | GPIO, PWM, I2C, SPI, UART, ADC | | LED System | WS2812 driver, multi-device multi-layer, effects engine, BMP/PNG/JPG/GIF | | Console | Command system, multi-language, script engine, configuration persistence | -| Networking | WiFi, Ethernet W5500, HTTP/HTTPS server | +| Networking | WiFi, Ethernet W5500, HTTP/HTTPS server, LPMU upstream network access | | Security | Session management, Token authentication, AES-GCM, RSA/EC, SSH client, PKI certificate management | | Drivers | Fan control, power monitoring (ADC/INA3221/PZEM), AGX/LPMU power control, USB MUX | | WebUI | REST API gateway, WebSocket broadcast, frontend dashboard | diff --git a/components/ts_api/CMakeLists.txt b/components/ts_api/CMakeLists.txt index 9cb5b68..4d5efc4 100644 --- a/components/ts_api/CMakeLists.txt +++ b/components/ts_api/CMakeLists.txt @@ -18,6 +18,7 @@ set(TS_API_SRCS "src/ts_api_wifi.c" "src/ts_api_dhcp.c" "src/ts_api_nat.c" + "src/ts_api_lpmu_access.c" "src/ts_api_hosts.c" "src/ts_api_key.c" "src/ts_api_ssh.c" @@ -31,9 +32,14 @@ set(TS_API_SRCS "src/ts_api_ui.c" ) +set(TS_API_EMBED_FILES + "assets/lpmu-agx-network-setup.tar.gz" +) + idf_component_register( SRCS ${TS_API_SRCS} INCLUDE_DIRS "include" + EMBED_FILES ${TS_API_EMBED_FILES} REQUIRES ts_core ts_drivers ts_led ts_net json ts_storage ts_security ts_ota ts_automation ts_cert ts_webui ts_config_pack PRIV_REQUIRES ts_hal esp_timer esp_app_format spi_flash bootloader_support esp_wifi app_update esp_https_ota esp_http_client espressif__esp_websocket_client ) diff --git a/components/ts_api/assets/lpmu-agx-network-setup.manifest b/components/ts_api/assets/lpmu-agx-network-setup.manifest new file mode 100644 index 0000000..770e78a --- /dev/null +++ b/components/ts_api/assets/lpmu-agx-network-setup.manifest @@ -0,0 +1,6 @@ +repo: RMinte/lpmu-agx-network-setup +ref: main +commit: 9ee3c51de5c9612e6e2c68d271ae329bd67b66ce +archive: lpmu-agx-network-setup.tar.gz +sha256: 77ac9410a62eea50b2a5079d3a7adb1012deee1188c03e0a5cfb4609e4e5f636 +size: 12874 diff --git a/components/ts_api/assets/lpmu-agx-network-setup.tar.gz b/components/ts_api/assets/lpmu-agx-network-setup.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..7106ec0380fc6917cc14dd94120c608ce83ce049 GIT binary patch literal 12874 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 jC#G<|3 +#include +#include +#include +#include +#include + +#define TAG "api_lpmu" + +#define LPMU_HOST "10.10.99.99" +#define LPMU_PORT 22 +#define LPMU_REMOTE_TARBALL "lpmu-agx-network-setup.tar.gz" +#define LPMU_STACK_SIZE 8192 +#define LPMU_OUTPUT_TAIL_MAX 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"); + +typedef enum { + LPMU_STAGE_IDLE = 0, + LPMU_STAGE_QUEUED, + LPMU_STAGE_FINDING_HOST, + LPMU_STAGE_LOADING_KEY, + LPMU_STAGE_CONNECTING, + LPMU_STAGE_VERIFYING_HOST, + LPMU_STAGE_CHECKING_REMOTE, + LPMU_STAGE_UPLOADING, + LPMU_STAGE_EXTRACTING, + LPMU_STAGE_CHMOD, + LPMU_STAGE_RUNNING_SCRIPT, + LPMU_STAGE_SUCCESS, + LPMU_STAGE_FAILED, +} lpmu_stage_t; + +typedef struct { + uint32_t run_id; + bool running; + lpmu_stage_t stage; + esp_err_t esp_error; + int exit_code; + uint32_t bytes_transferred; + uint32_t bytes_total; + uint32_t output_bytes; + size_t output_len; + char last_error[160]; + char output_tail[LPMU_OUTPUT_TAIL_MAX]; +} 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 lpmu_status_t s_status = { + .stage = LPMU_STAGE_IDLE, + .exit_code = -1, +}; + +static size_t lpmu_archive_size(void) +{ + return (size_t)(lpmu_archive_end - lpmu_archive_start); +} + +static void lpmu_secure_zero(void *ptr, size_t len) +{ + volatile uint8_t *p = (volatile uint8_t *)ptr; + while (len--) { + *p++ = 0; + } +} + +static const char *lpmu_stage_str(lpmu_stage_t stage) +{ + switch (stage) { + case LPMU_STAGE_IDLE: return "idle"; + case LPMU_STAGE_QUEUED: return "queued"; + case LPMU_STAGE_FINDING_HOST: return "finding_host"; + case LPMU_STAGE_LOADING_KEY: return "loading_key"; + case LPMU_STAGE_CONNECTING: return "connecting"; + case LPMU_STAGE_VERIFYING_HOST: return "verifying_host"; + case LPMU_STAGE_CHECKING_REMOTE: return "checking_remote"; + case LPMU_STAGE_UPLOADING: return "uploading"; + case LPMU_STAGE_EXTRACTING: return "extracting"; + case LPMU_STAGE_CHMOD: return "chmod"; + case LPMU_STAGE_RUNNING_SCRIPT: return "running_script"; + case LPMU_STAGE_SUCCESS: return "success"; + case LPMU_STAGE_FAILED: return "failed"; + default: return "unknown"; + } +} + +static esp_err_t lpmu_ensure_mutex(void) +{ + if (s_lpmu_mutex) { + return ESP_OK; + } + + s_lpmu_mutex = xSemaphoreCreateMutex(); + return s_lpmu_mutex ? ESP_OK : ESP_ERR_NO_MEM; +} + +static void lpmu_lock(void) +{ + if (s_lpmu_mutex) { + xSemaphoreTake(s_lpmu_mutex, portMAX_DELAY); + } +} + +static void lpmu_unlock(void) +{ + if (s_lpmu_mutex) { + xSemaphoreGive(s_lpmu_mutex); + } +} + +static void lpmu_set_stage(lpmu_stage_t stage) +{ + lpmu_lock(); + s_status.stage = stage; + lpmu_unlock(); +} + +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.esp_error = err; + s_status.exit_code = exit_code; + vsnprintf(s_status.last_error, sizeof(s_status.last_error), fmt, args); +} + +static void lpmu_fail(esp_err_t err, int exit_code, const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + lpmu_lock(); + lpmu_set_error_locked(err, exit_code, fmt, args); + lpmu_unlock(); + va_end(args); +} + +static void lpmu_success(int exit_code) +{ + lpmu_lock(); + s_status.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_append_tail_locked(const char *data, size_t len) +{ + const size_t cap = sizeof(s_status.output_tail) - 1; + + if (!data || len == 0 || cap == 0) { + return; + } + + if (UINT32_MAX - s_status.output_bytes < len) { + s_status.output_bytes = UINT32_MAX; + } else { + s_status.output_bytes += (uint32_t)len; + } + + if (len >= cap) { + memcpy(s_status.output_tail, data + len - cap, cap); + s_status.output_len = cap; + s_status.output_tail[cap] = '\0'; + return; + } + + if (s_status.output_len + len > cap) { + size_t drop = s_status.output_len + len - cap; + memmove(s_status.output_tail, s_status.output_tail + drop, s_status.output_len - drop); + s_status.output_len -= drop; + } + + memcpy(s_status.output_tail + s_status.output_len, data, len); + s_status.output_len += len; + s_status.output_tail[s_status.output_len] = '\0'; +} + +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); + lpmu_unlock(); +} + +static esp_err_t lpmu_run_command(ts_ssh_session_t session, const char *command, int *exit_code) +{ + int code = -1; + esp_err_t ret = ts_ssh_exec_stream(session, command, lpmu_output_cb, NULL, &code); + if (exit_code) { + *exit_code = code; + } + return ret; +} + +static bool lpmu_verify_known_host(ts_ssh_session_t session, char *err, size_t err_size) +{ + ts_host_verify_result_t verify_result = TS_HOST_VERIFY_ERROR; + ts_known_host_t host_info = {0}; + esp_err_t ret = ts_known_hosts_verify(session, &verify_result, &host_info); + + if (ret != ESP_OK) { + snprintf(err, err_size, "known-host verification failed: %s", esp_err_to_name(ret)); + return false; + } + + if (verify_result == TS_HOST_VERIFY_OK) { + return true; + } + + if (verify_result == TS_HOST_VERIFY_NOT_FOUND) { + snprintf(err, err_size, "known-host missing for %s:%d", LPMU_HOST, LPMU_PORT); + } else if (verify_result == TS_HOST_VERIFY_MISMATCH) { + snprintf(err, err_size, "known-host mismatch for %s:%d", LPMU_HOST, LPMU_PORT); + } else { + snprintf(err, err_size, "known-host verification error for %s:%d", LPMU_HOST, LPMU_PORT); + } + return false; +} + +static void lpmu_task(void *arg) +{ + (void)arg; + + ts_ssh_session_t session = NULL; + char *private_key = NULL; + size_t private_key_len = 0; + ts_ssh_host_config_t host_cfg = {0}; + int exit_code = -1; + esp_err_t ret; + char verify_error[128] = {0}; + + lpmu_set_stage(LPMU_STAGE_FINDING_HOST); + ret = ts_ssh_hosts_config_find(LPMU_HOST, LPMU_PORT, NULL, &host_cfg); + if (ret != ESP_OK) { + lpmu_fail(ret, -1, "SSH host %s:%d is not configured", LPMU_HOST, LPMU_PORT); + goto cleanup; + } + + if (!host_cfg.enabled || host_cfg.auth_type != TS_SSH_HOST_AUTH_KEY || !host_cfg.keyid[0]) { + lpmu_fail(ESP_ERR_INVALID_STATE, -1, "SSH host %s:%d must use enabled key auth", LPMU_HOST, LPMU_PORT); + goto cleanup; + } + + lpmu_set_stage(LPMU_STAGE_LOADING_KEY); + ret = ts_keystore_load_private_key(host_cfg.keyid, &private_key, &private_key_len); + if (ret != ESP_OK) { + lpmu_fail(ret, -1, "failed to load SSH key '%s': %s", host_cfg.keyid, esp_err_to_name(ret)); + goto cleanup; + } + + ts_ssh_config_t config = TS_SSH_DEFAULT_CONFIG(); + config.host = host_cfg.host; + config.port = host_cfg.port ? host_cfg.port : LPMU_PORT; + config.username = host_cfg.username; + config.auth_method = TS_SSH_AUTH_PUBLICKEY; + config.auth.key.private_key = (const uint8_t *)private_key; + config.auth.key.private_key_len = private_key_len; + config.auth.key.private_key_path = NULL; + config.auth.key.passphrase = NULL; + config.timeout_ms = 30000; + config.verify_host_key = false; + + lpmu_set_stage(LPMU_STAGE_CONNECTING); + ret = ts_ssh_session_create(&config, &session); + if (ret != ESP_OK) { + lpmu_fail(ret, -1, "failed to create SSH session: %s", esp_err_to_name(ret)); + goto cleanup; + } + + ret = ts_ssh_connect(session); + if (ret != ESP_OK) { + lpmu_fail(ret, -1, "SSH connect failed: %s", ts_ssh_get_error(session)); + goto cleanup; + } + + lpmu_set_stage(LPMU_STAGE_VERIFYING_HOST); + if (!lpmu_verify_known_host(session, verify_error, sizeof(verify_error))) { + lpmu_fail(ESP_ERR_INVALID_STATE, -1, "%s", verify_error); + goto cleanup; + } + + lpmu_set_stage(LPMU_STAGE_CHECKING_REMOTE); + ret = lpmu_run_command(session, + "test -f \"$HOME/lpmu-agx-network-setup/lpmu/setup-smart-route.sh\"", + &exit_code); + if (ret != ESP_OK) { + lpmu_fail(ret, exit_code, "remote check failed: %s", ts_ssh_get_error(session)); + goto cleanup; + } + + if (exit_code != 0) { + lpmu_set_stage(LPMU_STAGE_UPLOADING); + lpmu_lock(); + s_status.bytes_transferred = 0; + s_status.bytes_total = (uint32_t)lpmu_archive_size(); + lpmu_unlock(); + + ret = ts_scp_send_buffer(session, lpmu_archive_start, lpmu_archive_size(), + LPMU_REMOTE_TARBALL, 0644); + if (ret != ESP_OK) { + lpmu_fail(ret, -1, "SCP upload failed: %s", esp_err_to_name(ret)); + goto cleanup; + } + + lpmu_lock(); + s_status.bytes_transferred = (uint32_t)lpmu_archive_size(); + lpmu_unlock(); + + lpmu_set_stage(LPMU_STAGE_EXTRACTING); + ret = lpmu_run_command(session, + "mkdir -p \"$HOME/lpmu-agx-network-setup\" && " + "tar -xzf \"$HOME/lpmu-agx-network-setup.tar.gz\" " + "-C \"$HOME/lpmu-agx-network-setup\" --strip-components=1", + &exit_code); + if (ret != ESP_OK || exit_code != 0) { + lpmu_fail(ret != ESP_OK ? ret : ESP_FAIL, exit_code, + "remote extract failed (exit=%d): %s", exit_code, + ret != ESP_OK ? ts_ssh_get_error(session) : "tar command failed"); + goto cleanup; + } + } + + lpmu_set_stage(LPMU_STAGE_CHMOD); + ret = lpmu_run_command(session, + "chmod +x \"$HOME/lpmu-agx-network-setup/lpmu/\"*.sh", + &exit_code); + if (ret != ESP_OK || exit_code != 0) { + lpmu_fail(ret != ESP_OK ? ret : ESP_FAIL, exit_code, + "remote chmod failed (exit=%d): %s", exit_code, + ret != ESP_OK ? ts_ssh_get_error(session) : "chmod command failed"); + goto cleanup; + } + + 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); + 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, + ret != ESP_OK ? ts_ssh_get_error(session) : "remote script failed"); + goto cleanup; + } + + lpmu_success(exit_code); + +cleanup: + if (session) { + ts_ssh_disconnect(session); + ts_ssh_session_destroy(session); + } + if (private_key) { + lpmu_secure_zero(private_key, private_key_len); + free(private_key); + } + + lpmu_lock(); + s_status.running = false; + s_lpmu_task = NULL; + lpmu_unlock(); + + vTaskDelete(NULL); +} + +static esp_err_t api_lpmu_access_start(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"); + return ret; + } + + uint32_t run_id; + lpmu_lock(); + if (s_status.running || s_lpmu_task != NULL) { + lpmu_unlock(); + ts_api_result_error(result, TS_API_ERR_BUSY, "LPMU access task is already running"); + return ESP_ERR_INVALID_STATE; + } + + memset(&s_status, 0, sizeof(s_status)); + s_status.run_id = ++s_next_run_id; + if (s_next_run_id == 0) { + s_next_run_id = 1; + s_status.run_id = 1; + } + s_status.running = true; + s_status.stage = LPMU_STAGE_QUEUED; + s_status.exit_code = -1; + s_status.bytes_total = (uint32_t)lpmu_archive_size(); + run_id = s_status.run_id; + lpmu_unlock(); + + BaseType_t task_ret = xTaskCreate(lpmu_task, "lpmu_access", LPMU_STACK_SIZE, + NULL, 5, &s_lpmu_task); + if (task_ret != pdPASS) { + 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(); + ts_api_result_error(result, TS_API_ERR_NO_MEM, "Failed to create LPMU access task"); + return ESP_ERR_NO_MEM; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "run_id", run_id); + cJSON_AddStringToObject(data, "stage", lpmu_stage_str(LPMU_STAGE_QUEUED)); + cJSON_AddBoolToObject(data, "running", true); + ts_api_result_ok(result, data); + return ESP_OK; +} + +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"); + return ret; + } + + lpmu_lock(); + lpmu_status_t snap = s_status; + lpmu_unlock(); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "run_id", snap.run_id); + cJSON_AddBoolToObject(data, "running", snap.running); + cJSON_AddStringToObject(data, "stage", lpmu_stage_str(snap.stage)); + cJSON_AddNumberToObject(data, "exit_code", snap.exit_code); + cJSON_AddNumberToObject(data, "esp_error", snap.esp_error); + cJSON_AddNumberToObject(data, "bytes_transferred", snap.bytes_transferred); + 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_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); + + ts_api_result_ok(result, data); + return ESP_OK; +} + +static const ts_api_endpoint_t lpmu_access_endpoints[] = { + { + .name = "network.lpmu_access.start", + .description = "Start LPMU upper-network access setup", + .category = TS_API_CAT_NETWORK, + .handler = api_lpmu_access_start, + .requires_auth = true, + .permission = "network.config", + }, + { + .name = "network.lpmu_access.status", + .description = "Get LPMU upper-network access setup status", + .category = TS_API_CAT_NETWORK, + .handler = api_lpmu_access_status, + .requires_auth = true, + .permission = "network.view", + }, +}; + +esp_err_t ts_api_lpmu_access_register(void) +{ + esp_err_t ret = lpmu_ensure_mutex(); + if (ret != ESP_OK) { + return ret; + } + + return ts_api_register_multiple(lpmu_access_endpoints, + sizeof(lpmu_access_endpoints) / sizeof(lpmu_access_endpoints[0])); +} diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index 7c66189..e04ce1d 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -4732,6 +4732,16 @@ button.btn-gray:hover, gap: 8px; } +.lpmu-access-btn, +.btn.btn-sm.lpmu-access-btn { + flex: 0 0 auto; + min-height: 30px; + padding: 5px 12px; + font-size: 0.85rem; + line-height: 1.2; + border-radius: var(--radius-sm); +} + /* WiFi 网络卡片 */ .wifi-networks { display: grid; diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index fa06ae8..06b39b8 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -353,6 +353,8 @@ 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 lpmuAccessStatus() { return this.call('network.lpmu_access.status'); } // WiFi 相关 async wifiMode(mode = null) { diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 8b2ef3d..ee0651b 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -632,7 +632,7 @@ async function loadSystemPage() { - +

@@ -7060,9 +7060,15 @@ async function stopFilter() { // 网络页面 // ========================================================================= +const NETWORK_LPMU_ACCESS_POLL_MS = 1500; +let networkLpmuAccessPollTimer = null; +let networkLpmuAccessRequesting = false; +let networkLpmuAccessLastInfo = { stage: 'idle', running: false }; + async function loadNetworkPage() { clearInterval(refreshInterval); stopServiceStatusRefresh(); + stopNetworkLpmuAccessPolling(); // 取消系统页面的订阅 if (subscriptionManager) { @@ -7260,6 +7266,20 @@ async function loadNetworkPage() {
+ +
+
+

${typeof t === 'function' ? t('networkPage.lpmuAccessTitle') : '接入上层网络'}

+ +
+
+
+ ${typeof t === 'function' ? t('networkPage.lpmuAccessIdle') : '未启动'} +
+
+
+
+