diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b3ab7b..4a17d90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,6 +77,10 @@ jobs: - name: Set target run: | source ~/esp/esp-idf/export.sh + # The repository tracks release artifacts under build/, but that + # checkout is not a complete CMake build tree. Remove it before + # idf.py set-target, otherwise ESP-IDF refuses its implicit fullclean. + rm -rf build idf.py set-target ${{ env.TARGET }} - name: Build firmware diff --git a/.gitignore b/.gitignore index d7ce5ee..cbfc4a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,15 @@ -# Build output -build/ +# Build intermediates +build/* +!build/TianShanOS.bin +!build/www.bin +!build/ota_data_initial.bin +!build/flasher_args.json +!build/bootloader/ +build/bootloader/* +!build/bootloader/bootloader.bin +!build/partition_table/ +build/partition_table/* +!build/partition_table/partition-table.bin # ESP-IDF managed components (downloaded dependencies) managed_components/ @@ -48,4 +58,4 @@ password*.txt # Documentation frontend_modification.md -frontend_emoji_list.md \ No newline at end of file +frontend_emoji_list.md diff --git a/OTA_Server/server.py b/OTA_Server/server.py index a6a5664..c003fb1 100644 --- a/OTA_Server/server.py +++ b/OTA_Server/server.py @@ -36,6 +36,40 @@ BUILD_DIR = Path(__file__).parent.parent / "build" FIRMWARE_NAME = "TianShanOS.bin" WWW_NAME = "www.bin" +ESP_APP_DESC_MAGIC = 0xABCD5432 +ESP_APP_DESC_OFFSET = 0x20 +ESP_APP_DESC_SIZE = 0xC4 + + +def read_c_string(data, start, end): + """Read a null-terminated UTF-8 string from a fixed-width binary field.""" + return data[start:end].split(b'\x00', 1)[0].decode('utf-8', errors='replace') + + +def parse_firmware_app_desc(firmware_path): + """Parse ESP-IDF esp_app_desc_t metadata embedded in an app image.""" + try: + with open(firmware_path, 'rb') as f: + f.seek(ESP_APP_DESC_OFFSET) + app_desc = f.read(ESP_APP_DESC_SIZE) + except OSError: + return None + + if len(app_desc) < ESP_APP_DESC_SIZE: + return None + + magic = struct.unpack('req, "Vary", "Accept-Encoding"); } - /* Cache-Control:index.html 不缓存(确保更新),其他静态资源长期缓存 */ + /* Static filenames are not content-hashed, so JS/CSS must revalidate after + * a WebUI partition update. Fonts/images can still be cached briefly. + */ const char *basename = strrchr(filepath, '/'); if (basename && strcmp(basename, "/index.html") == 0) { - httpd_resp_set_hdr(req->req, "Cache-Control", "no-cache"); + httpd_resp_set_hdr(req->req, "Cache-Control", "no-cache, no-store, must-revalidate"); + } else if (ext && (strcmp(ext, ".js") == 0 || strcmp(ext, ".css") == 0)) { + httpd_resp_set_hdr(req->req, "Cache-Control", "no-cache, no-store, must-revalidate"); } else if (ext) { - /* JS/CSS/字体/图片:缓存 7 天 */ - httpd_resp_set_hdr(req->req, "Cache-Control", "public, max-age=604800, immutable"); + httpd_resp_set_hdr(req->req, "Cache-Control", "public, max-age=3600"); } /* ===== 发送文件内容 ===== */ diff --git a/components/ts_ota/include/ts_ota.h b/components/ts_ota/include/ts_ota.h index 6bdaa9d..7521d8a 100644 --- a/components/ts_ota/include/ts_ota.h +++ b/components/ts_ota/include/ts_ota.h @@ -467,7 +467,7 @@ esp_err_t ts_ota_www_get_progress(ts_ota_progress_t *progress); * * Recovery will: * 1. Flash firmware to OTA partition - * 2. Flash WebUI to www partition (if present) + * 2. Flash WebUI to www partition unless manifest sets "www" to an empty string * 3. Delete recovery files (if delete_after is true) * 4. Reboot device * diff --git a/components/ts_ota/src/ts_ota_download.c b/components/ts_ota/src/ts_ota_download.c index 26a5524..daff5e7 100644 --- a/components/ts_ota/src/ts_ota_download.c +++ b/components/ts_ota/src/ts_ota_download.c @@ -758,10 +758,13 @@ static esp_err_t create_manifest_file(const char *firmware_version, const char * } if (firmware_version) { + cJSON_AddStringToObject(manifest, "version", firmware_version); cJSON_AddStringToObject(manifest, "firmware_version", firmware_version); + cJSON_AddStringToObject(manifest, "firmware", "TianShanOS.bin"); } if (www_version) { cJSON_AddStringToObject(manifest, "www_version", www_version); + cJSON_AddStringToObject(manifest, "www", "www.bin"); } cJSON_AddBoolToObject(manifest, "force", force); cJSON_AddBoolToObject(manifest, "delete_after", true); diff --git a/components/ts_ota/src/ts_ota_recovery.c b/components/ts_ota/src/ts_ota_recovery.c index 47b55a7..e293974 100644 --- a/components/ts_ota/src/ts_ota_recovery.c +++ b/components/ts_ota/src/ts_ota_recovery.c @@ -11,7 +11,7 @@ * 目录结构: * /sdcard/recovery/ * ├── TianShanOS.bin # 固件文件 - * ├── www.bin # WebUI 文件(可选) + * ├── www.bin # WebUI 文件 * └── manifest.json # 版本清单 * * manifest.json 格式: @@ -22,6 +22,8 @@ * "www": "www.bin" * } * + * 如确需 app-only 恢复,可在 manifest 中显式设置 "www": ""。 + * * @author TianShanOS Team * @version 1.0.0 */ @@ -475,6 +477,16 @@ esp_err_t ts_ota_check_recovery(void) return ESP_ERR_NOT_FOUND; } + /* Boot-time recovery is a complete package path. Validate the WebUI image + * before changing the boot partition so a partial SD card package cannot + * leave the device with a new app but no usable /www UI. + */ + if (manifest.www[0] && stat(www_path, &st) != 0) { + ESP_LOGE(TAG, "WebUI file required for recovery but not found: %s", www_path); + ESP_LOGE(TAG, "Copy www.bin next to TianShanOS.bin or set manifest www to an empty string for app-only recovery"); + return ESP_ERR_NOT_FOUND; + } + // 执行固件恢复 ret = do_firmware_recovery(firmware_path); if (ret != ESP_OK) { @@ -483,14 +495,14 @@ esp_err_t ts_ota_check_recovery(void) } firmware_done = true; - // 执行 WebUI 恢复(可选) - if (stat(www_path, &st) == 0) { + // 执行 WebUI 恢复 + if (manifest.www[0]) { ret = do_www_recovery(www_path); if (ret != ESP_OK) { - ESP_LOGW(TAG, "WebUI recovery failed (non-fatal): %s", esp_err_to_name(ret)); - } else { - www_done = true; + ESP_LOGE(TAG, "❌ WebUI recovery failed: %s", esp_err_to_name(ret)); + return ret; } + www_done = true; } // 清理 recovery 文件 diff --git a/components/ts_ota/src/ts_ota_sdcard.c b/components/ts_ota/src/ts_ota_sdcard.c index 7a15e0a..a3ea146 100644 --- a/components/ts_ota/src/ts_ota_sdcard.c +++ b/components/ts_ota/src/ts_ota_sdcard.c @@ -23,6 +23,7 @@ static const char *TAG = "ts_ota_sdcard"; // OTA task handle static TaskHandle_t s_ota_task_handle = NULL; static ts_ota_config_t s_ota_config; +static char *s_ota_url = NULL; static bool s_ota_running = false; // Forward declarations @@ -51,8 +52,22 @@ esp_err_t ts_ota_start_sdcard(const ts_ota_config_t *config) ESP_LOGI(TAG, "Firmware file: %s, size: %ld bytes", config->url, st.st_size); - // Copy config + size_t url_len = strlen(config->url) + 1; + char *url_copy = OTA_MALLOC(url_len); + if (!url_copy) { + ESP_LOGE(TAG, "Failed to allocate URL buffer"); + return ESP_ERR_NO_MEM; + } + memcpy(url_copy, config->url, url_len); + + if (s_ota_url) { + free(s_ota_url); + } + s_ota_url = url_copy; + + // Copy config and keep the file path valid for the OTA task lifetime. memcpy(&s_ota_config, config, sizeof(ts_ota_config_t)); + s_ota_config.url = s_ota_url; // Create OTA task BaseType_t ret = xTaskCreate( @@ -66,6 +81,9 @@ esp_err_t ts_ota_start_sdcard(const ts_ota_config_t *config) if (ret != pdPASS) { ESP_LOGE(TAG, "Failed to create OTA task"); + free(s_ota_url); + s_ota_url = NULL; + s_ota_config.url = NULL; return ESP_ERR_NO_MEM; } @@ -314,6 +332,11 @@ static void sdcard_ota_task(void *arg) s_ota_running = false; s_ota_task_handle = NULL; + if (s_ota_url) { + free(s_ota_url); + s_ota_url = NULL; + s_ota_config.url = NULL; + } vTaskDelete(NULL); } diff --git a/components/ts_webui/src/ts_webui.c b/components/ts_webui/src/ts_webui.c index e20845a..3184c7b 100644 --- a/components/ts_webui/src/ts_webui.c +++ b/components/ts_webui/src/ts_webui.c @@ -20,7 +20,16 @@ static bool s_running = false; static esp_err_t static_file_handler(ts_http_request_t *req, void *user_data) { - const char *uri = req->uri; + char uri_buf[96]; + const char *query = strchr(req->uri, '?'); + size_t uri_len = query ? (size_t)(query - req->uri) : strlen(req->uri); + if (uri_len >= sizeof(uri_buf)) { + uri_len = sizeof(uri_buf) - 1; + } + memcpy(uri_buf, req->uri, uri_len); + uri_buf[uri_len] = '\0'; + + const char *uri = uri_buf; char filepath[128]; #ifdef CONFIG_TS_WEBUI_STATIC_PATH diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index 4f452af..78583e4 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -41,9 +41,9 @@ .modal-content{background:var(--bg-card);border-radius:var(--radius-lg);max-width:600px;width:95%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)} - - - + + + @@ -247,6 +247,7 @@

电压保护设置

- - - - - + + + + + diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 0aacf13..d235871 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -15931,6 +15931,74 @@ async function refreshOtaInfo() { let otaStep = 'idle'; // 'idle' | 'app' | 'www' let wwwOtaEnabled = true; // 是否启用 WebUI 升级 let sdcardOtaSource = ''; // SD卡升级时的文件路径,用于推导 www.bin 路径 +let sdcardWwwSource = ''; // SD卡升级时实际匹配到的 www.bin 路径 + +function inferSdcardWwwPath(source) { + if (!source) return ''; + return source.match(/\.bin$/i) + ? source.replace(/[^\/]+\.bin$/i, 'www.bin') + : source.replace(/\/?$/, '/www.bin'); +} + +async function resolveSdcardOtaPaths(input, includeWww) { + const source = input.trim(); + if (!source) { + throw new Error('请输入文件或目录路径'); + } + + if (source.match(/\.bin$/i)) { + const name = source.substring(source.lastIndexOf('/') + 1).toLowerCase(); + if (name === 'www.bin') { + const dir = source.substring(0, source.lastIndexOf('/')) || '/sdcard'; + const resolved = await resolveSdcardOtaPaths(dir, includeWww); + return { + firmware: resolved.firmware, + www: includeWww ? source : '' + }; + } + + const firmwareInfo = await api.storageInfo(source); + if (firmwareInfo.code !== 0 || firmwareInfo.data?.type !== 'file') { + throw new Error(`未找到固件文件: ${source}`); + } + + const wwwPath = inferSdcardWwwPath(source); + if (includeWww) { + const wwwInfo = await api.storageInfo(wwwPath); + if (wwwInfo.code !== 0 || wwwInfo.data?.type !== 'file') { + throw new Error(`未找到 WebUI 文件: ${wwwPath}`); + } + } + + return { firmware: source, www: includeWww ? wwwPath : '' }; + } + + const dir = source.replace(/\/+$/, '') || '/sdcard'; + const list = await api.storageList(dir); + if (list.code !== 0 || !Array.isArray(list.data?.entries)) { + throw new Error(`无法读取目录: ${dir}`); + } + + const files = list.data.entries.filter(entry => entry.type === 'file'); + const firmware = files.find(entry => entry.name === 'TianShanOS.bin') || + files.find(entry => entry.name.toLowerCase() === 'tianshanos.bin') || + files.find(entry => entry.name.toLowerCase().endsWith('.bin') && entry.name.toLowerCase() !== 'www.bin'); + + if (!firmware) { + throw new Error(`目录中未找到固件 .bin: ${dir}`); + } + + let wwwPath = ''; + if (includeWww) { + const www = files.find(entry => entry.name.toLowerCase() === 'www.bin'); + if (!www) { + throw new Error(`目录中未找到 WebUI 文件: ${dir}/www.bin`); + } + wwwPath = `${dir}/${www.name}`; + } + + return { firmware: `${dir}/${firmware.name}`, www: wwwPath }; +} async function refreshOtaProgress() { try { @@ -16051,11 +16119,7 @@ async function startWwwOta() { if (sdcardOtaSource) { // SD卡升级:推导 www.bin 路径 isFromSdcard = true; - if (sdcardOtaSource.match(/\.bin$/i)) { - wwwSource = sdcardOtaSource.replace(/[^\/]+\.bin$/i, 'www.bin'); - } else { - wwwSource = sdcardOtaSource.replace(/\/?$/, '/www.bin'); - } + wwwSource = sdcardWwwSource || inferSdcardWwwPath(sdcardOtaSource); } else { // HTTP 升级:从服务器 URL 推导 const serverUrl = document.getElementById('ota-server-input').value.trim() || @@ -16079,6 +16143,7 @@ async function startWwwOta() { console.log('No www source configured, skipping WebUI upgrade'); wwwOtaEnabled = false; sdcardOtaSource = ''; // 重置 + sdcardWwwSource = ''; return; } @@ -16104,36 +16169,36 @@ async function startWwwOta() { }); } - sdcardOtaSource = ''; // 重置 - if (result.code !== 0) { showToast(typeof t === 'function' ? t('toast.webuiUpgradeStartFailed') + ': ' + result.message : 'WebUI 升级启动失败: ' + result.message, 'error'); - // 即使 www 失败也继续重启(因为 app 已经更新) otaStep = 'idle'; clearInterval(refreshInterval); refreshInterval = null; - document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.firmwareOnlyComplete') : '固件升级完成(WebUI 跳过)'; + document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; document.getElementById('ota-message').innerHTML = `
-

固件已更新,WebUI 升级跳过,设备正在重启...

-

正在触发重启...

+

固件已写入,但 WebUI 升级失败,已停止重启。

+

${result.message || '请确认 www.bin 与固件在同一目录后重试'}

+
`; - - // 触发设备重启 - try { - await api.call('system.reboot', { delay: 1 }); - } catch (e) { - console.log('Reboot triggered (connection may have closed)'); - } - - startRebootDetection(); + document.getElementById('ota-abort-btn').style.display = 'none'; + return; + } + + sdcardOtaSource = ''; // 重置 + sdcardWwwSource = ''; + if (!refreshInterval) { + refreshInterval = setInterval(refreshOtaProgress, 1000); } + await refreshOtaProgress(); } catch (error) { console.error('Failed to start WWW OTA:', error); otaStep = 'idle'; - sdcardOtaSource = ''; // 重置 + document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; + document.getElementById('ota-message').textContent = error.message || 'WebUI 升级启动失败'; + document.getElementById('ota-abort-btn').style.display = 'none'; } } @@ -16264,23 +16329,32 @@ async function otaFromUrl() { } async function otaFromFile() { - const filepath = document.getElementById('ota-file-input').value.trim(); - if (!filepath) { + const inputPath = document.getElementById('ota-file-input').value.trim(); + if (!inputPath) { showToast(typeof t === 'function' ? t('toast.enterFilePath') : '请输入文件路径', 'error'); return; } const includeWww = document.getElementById('ota-file-include-www').checked; + + let paths; + try { + paths = await resolveSdcardOtaPaths(inputPath, includeWww); + } catch (error) { + showToast(error.message || '无法解析 SD 卡升级文件', 'error'); + return; + } const params = { - file: filepath, + file: paths.firmware, no_reboot: true // 不自动重启,由前端控制流程 }; // 设置 OTA 步骤 otaStep = 'app'; wwwOtaEnabled = includeWww; // 根据用户选择决定是否升级 www - sdcardOtaSource = filepath; // 保存 SD 卡路径用于推导 www.bin 路径 + sdcardOtaSource = paths.firmware; // 保存 SD 卡路径用于推导 www.bin 路径 + sdcardWwwSource = paths.www; // 立即显示进度区域 const progressSection = document.getElementById('ota-progress-section'); @@ -16290,7 +16364,7 @@ async function otaFromFile() { document.getElementById('ota-progress-bar').style.width = '0%'; document.getElementById('ota-progress-percent').textContent = '0%'; document.getElementById('ota-progress-size').textContent = typeof t === 'function' ? t('otaPage.preparing') : '准备中...'; - document.getElementById('ota-message').textContent = filepath; + document.getElementById('ota-message').textContent = paths.firmware; document.getElementById('ota-abort-btn').style.display = 'inline-block'; try { @@ -16752,7 +16826,7 @@ async function upgradeViaProxy(serverUrl) { // ===== 第三步:处理 WebUI(如果启用)===== if (includeWww) { updateStep(3, '下载 WebUI...'); - const wwwUrl = serverUrl.replace(/\/$/, '') + '/www'; + const wwwUrl = serverUrl.replace(/\/$/, '') + '/www.bin'; messageEl.textContent = typeof t === 'function' ? t('otaPage.downloadingFromServer') : '从 OTA 服务器下载'; progressBar.style.width = '0%'; progressPercent.textContent = '0%'; @@ -16780,17 +16854,16 @@ async function upgradeViaProxy(serverUrl) { const wwwResult = await uploadWwwToDevice(wwwData); if (!wwwResult.success) { - console.warn('WWW upload failed:', wwwResult.error); - showToast(typeof t === 'function' ? t('toast.webuiUpgradeSkipped', { msg: wwwResult.error }) : 'WebUI 升级跳过: ' + wwwResult.error, 'warning'); - } else { - console.log('Proxy OTA: WWW uploaded to device'); - showToast(typeof t === 'function' ? t('toast.webuiWriteComplete') : 'WebUI 写入完成!', 'success'); - progressBar.style.width = '100%'; - progressPercent.textContent = ''; + throw new Error(wwwResult.error || 'WebUI 上传失败'); } + + console.log('Proxy OTA: WWW uploaded to device'); + showToast(typeof t === 'function' ? t('toast.webuiWriteComplete') : 'WebUI 写入完成!', 'success'); + progressBar.style.width = '100%'; + progressPercent.textContent = ''; } catch (wwwError) { console.warn('WWW download/upload failed:', wwwError); - showToast(typeof t === 'function' ? t('toast.webuiUpgradeSkipped', { msg: wwwError.message }) : 'WebUI 升级跳过: ' + wwwError.message, 'warning'); + throw new Error('WebUI 升级失败: ' + (wwwError.message || wwwError)); } } @@ -16926,6 +16999,7 @@ async function uploadWwwToDevice(wwwData) { window.loadOtaPage = loadOtaPage; window.otaFromUrl = otaFromUrl; window.otaFromFile = otaFromFile; +window.startWwwOta = startWwwOta; window.validateOta = validateOta; window.confirmRollback = confirmRollback; window.rollbackOta = rollbackOta; diff --git a/tools/gen_version.py b/tools/gen_version.py index 2361306..ed716c6 100644 --- a/tools/gen_version.py +++ b/tools/gen_version.py @@ -43,7 +43,12 @@ def is_git_dirty(repo_path: Path) -> bool: timeout=5 ) if result.returncode == 0: - return bool(result.stdout.strip()) + for line in result.stdout.splitlines(): + path = line[3:] + if path.startswith('build/'): + continue + return True + return False except Exception: pass return False diff --git a/version.txt b/version.txt index 76914dd..8f0916f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.4.9 +0.5.0