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 0000000..7106ec0 Binary files /dev/null and b/components/ts_api/assets/lpmu-agx-network-setup.tar.gz differ diff --git a/components/ts_api/include/ts_api.h b/components/ts_api/include/ts_api.h index 4bac8f5..e9c3801 100644 --- a/components/ts_api/include/ts_api.h +++ b/components/ts_api/include/ts_api.h @@ -349,6 +349,11 @@ esp_err_t ts_api_dhcp_register(void); */ esp_err_t ts_api_nat_register(void); +/** + * @brief Register LPMU upper-network access APIs + */ +esp_err_t ts_api_lpmu_access_register(void); + /** * @brief Register SSH known hosts APIs */ diff --git a/components/ts_api/src/ts_api.c b/components/ts_api/src/ts_api.c index 4b0c098..c4f2404 100644 --- a/components/ts_api/src/ts_api.c +++ b/components/ts_api/src/ts_api.c @@ -632,6 +632,13 @@ esp_err_t ts_api_register_all(void) TS_LOGE(TAG, "Failed to register NAT APIs: %s", esp_err_to_name(ret)); return ret; } + + /* LPMU upper network access APIs */ + ret = ts_api_lpmu_access_register(); + if (ret != ESP_OK) { + TS_LOGE(TAG, "Failed to register LPMU access APIs: %s", esp_err_to_name(ret)); + return ret; + } /* SSH Known Hosts APIs */ ret = ts_api_hosts_register(); diff --git a/components/ts_api/src/ts_api_lpmu_access.c b/components/ts_api/src/ts_api_lpmu_access.c new file mode 100644 index 0000000..2fc9e65 --- /dev/null +++ b/components/ts_api/src/ts_api_lpmu_access.c @@ -0,0 +1,500 @@ +/** + * @file ts_api_lpmu_access.c + * @brief LPMU upper-network access API + */ + +#include "ts_api.h" +#include "ts_core.h" +#include "ts_keystore.h" +#include "ts_known_hosts.h" +#include "ts_scp.h" +#include "ts_ssh_client.h" +#include "ts_ssh_hosts_config.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include +#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') : '未启动'} +
+
+
+
+