diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin
index a838615..c29b2e6 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 2437942..9240141 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
index 4d313cc..9e7036f 100644
--- a/build/firmware-manifest.json
+++ b/build/firmware-manifest.json
@@ -1,17 +1,17 @@
{
"version": "0.6.2",
- "build_version": "0.6.2+028e6330.10132311",
+ "build_version": "0.6.2+fedbf9cc.10140010",
"idf_version": "5.5.2",
- "source_base_commit": "028e63309255045957e86fcb73cc328cf5d95997",
- "source_inputs_sha256": "a0c0f9b6b138c17f4cedeb3d902135882c9e28204987ec6338f7ece0459aac0e",
+ "source_base_commit": "fedbf9cc3f8b86b8a57c1e8a2f536080dbad1561",
+ "source_inputs_sha256": "c90243b39cc3a8ae8b32a134897e92509301b4fe56beeba0d4b8860a57de5ff0",
"files": {
"TianShanOS.bin": {
"bytes": 2226000,
- "sha256": "f6e9f7ca1c22dd6aeaabfa6a36c0c38511ef013dbde999596c150dcbc20e382f"
+ "sha256": "1d44568f570252f9b653292c4c2e8fc83e42f45ee1c2962a37de0487101b5278"
},
"www.bin": {
"bytes": 3145728,
- "sha256": "3fbbd6a641be069593e00f3cebf4034feefb0fc5322f41c3145accbeb2d996b5"
+ "sha256": "f11956e6545c6fb789ccb0a99757e04f8beb886ed3083829100b16af6dfcc8d7"
},
"ota_data_initial.bin": {
"bytes": 8192,
@@ -23,7 +23,7 @@
},
"bootloader/bootloader.bin": {
"bytes": 22496,
- "sha256": "3f2254ead6037716cf2c7d2b6ac9ed8414b87779ea64ee52c63282274fbf0f24"
+ "sha256": "4fa4a1b8b644d2291bbf38109e3197450c4d754a152548a9262de24a39417d6d"
},
"partition_table/partition-table.bin": {
"bytes": 3072,
diff --git a/build/www.bin b/build/www.bin
index 7091518..b8c32b4 100644
Binary files a/build/www.bin and b/build/www.bin differ
diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js
index ababb77..47abf9c 100644
--- a/components/ts_webui/web/js/app.js
+++ b/components/ts_webui/web/js/app.js
@@ -13004,6 +13004,17 @@ async function loadOtaPage() {
${t('ota.firmwareUpgrade')}
+
+
+
+
${t('ota.upgradeProgress')}
+
+
+
0%
+
+
+
${t('ota.preparing')} · 0 / 0
+
@@ -13057,16 +13068,6 @@ async function loadOtaPage() {
-
-
-
-
-
-
0%
-
-
-
${t('ota.preparing')} · 0 / 0
-
@@ -13330,7 +13331,7 @@ async function refreshOtaProgress() {
stateEl.textContent = stepText + (stateMap[state] || state);
if (state !== 'idle') {
- progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', '');
+ progressSection.style.display = 'block';
// 更新进度条
document.getElementById('ota-progress-bar').style.width = percent + '%';
@@ -13629,7 +13630,7 @@ async function otaFromUrl() {
// 立即显示进度区域,提供即时反馈
const progressSection = document.getElementById('ota-progress-section');
- progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', '');
+ progressSection.style.display = 'block';
document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.step1Connecting') : '[1/2] 正在连接服务器...';
document.getElementById('ota-progress-bar').style.width = '0%';
document.getElementById('ota-progress-percent').textContent = '0%';
@@ -13695,7 +13696,7 @@ async function otaFromFile() {
// 立即显示进度区域
const progressSection = document.getElementById('ota-progress-section');
- progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', '');
+ progressSection.style.display = 'block';
const stepText = includeWww ? '[1/2] ' : '';
document.getElementById('ota-state-text').textContent = stepText + (typeof t === 'function' ? t('otaPage.readingFile') : '正在读取文件...');
document.getElementById('ota-progress-bar').style.width = '0%';
@@ -13797,9 +13798,11 @@ async function abortOta() {
if (upgrade) finishOtaUpgrade(upgrade);
otaStep = 'idle';
document.getElementById('ota-progress-section').style.display = 'none';
- await refreshOtaInfo();
+ // Finish cleanup before refreshing: the refresh can outlive this
+ // attempt while the user starts another upgrade.
clearInterval(refreshInterval);
refreshInterval = null;
+ await refreshOtaInfo();
} else {
if (upgrade) upgrade.phase = previousPhase;
reportFailure({ op: 'ota.abort', result, write: true });
@@ -14057,7 +14060,7 @@ async function upgradeFromServer() {
// 立即显示进度区域,给用户即时反馈
const progressSection = document.getElementById('ota-progress-section');
- progressSection.style.display = 'block'; document.getElementById('ota-manual')?.setAttribute('open', '');
+ progressSection.style.display = 'block';
document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('ui.preparingUpgrade') : 'Preparing upgrade...';
document.getElementById('ota-progress-bar').style.width = '0%';
document.getElementById('ota-progress-percent').textContent = '';
diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js
index 89fb19b..912b766 100644
--- a/components/ts_webui/web/js/lang/en-US.js
+++ b/components/ts_webui/web/js/lang/en-US.js
@@ -1081,6 +1081,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', {
downloading: 'Downloading',
installing: 'Installing',
progress: 'Progress',
+ upgradeProgress: 'Upgrade Progress',
upgradeSuccess: 'Upgrade successful, rebooting...',
upgradeFailed: 'Upgrade Failed',
rollback: 'Rollback',
diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js
index 0dfb307..2383d1e 100644
--- a/components/ts_webui/web/js/lang/zh-CN.js
+++ b/components/ts_webui/web/js/lang/zh-CN.js
@@ -1061,6 +1061,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', {
downloading: '下载中',
installing: '安装中',
progress: '进度',
+ upgradeProgress: '升级进度',
upgradeSuccess: '升级成功,即将重启...',
upgradeFailed: '升级失败',
rollback: '回滚',
diff --git a/tests/prompts/ota.browser.test.cjs b/tests/prompts/ota.browser.test.cjs
index f51aa36..711a574 100644
--- a/tests/prompts/ota.browser.test.cjs
+++ b/tests/prompts/ota.browser.test.cjs
@@ -100,7 +100,70 @@ async function returnToOtaPage(page) {
await page.waitForFunction(() => document.getElementById('ota-current-version')?.textContent.includes('0.6.'));
}
+async function expectIndependentProgress(page, abortVisible) {
+ assert.equal(await page.locator('#ota-progress-section').isVisible(), true);
+ assert.equal(await page.locator('#ota-abort-btn').isVisible(), abortVisible);
+ assert.deepEqual(await page.locator('#ota-progress-section').evaluate(section => ({
+ card: section.classList.contains('card'),
+ parent: section.parentElement.className,
+ previous: section.previousElementSibling.tagName,
+ folded: !!section.closest('details'),
+ title: section.querySelector('h2').textContent === t('ota.upgradeProgress')
+ })), {card: true, parent: 'ota-col', previous: 'H1', folded: false, title: true});
+ for (const width of [1440, 390]) {
+ await page.setViewportSize({width, height: 1000});
+ const card = await page.locator('#ota-progress-section').boundingBox();
+ const bar = await page.locator('#ota-progress-section .bar').boundingBox();
+ const fill = await page.locator('#ota-progress-bar').boundingBox();
+ const percent = await page.locator('#ota-progress-percent').boundingBox();
+ assert(card.x >= 0 && card.x + card.width <= width);
+ assert(bar.width > 0 && bar.x >= card.x && bar.x + bar.width <= card.x + card.width);
+ assert(fill.width >= 0 && fill.width <= bar.width);
+ assert(percent.x >= card.x && percent.x + percent.width <= card.x + card.width);
+ }
+}
+
for (const language of ['zh-CN', 'en-US']) {
+ test(`${language}: idle and check-update states keep the independent progress card hidden`, async () => {
+ await otaPage(language, 'manual-idle', async ({page, calls}) => {
+ assert.equal(await page.locator('.ota-col > .card').count(), 4);
+ assert.equal(await page.locator('#ota-progress-section').isVisible(), false);
+ await page.locator('[onclick="checkForUpdates()"]').click();
+ await page.locator('[onclick="upgradeFromServer()"]').waitFor();
+ assert.equal(await page.locator('#ota-progress-section').isVisible(), false);
+ assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false);
+ assert.equal(calls.filter(call => /ota\/(firmware|upgrade_url|upgrade_file)/.test(call.endpoint)).length, 0);
+ });
+ });
+
+ for (const source of ['URL', 'SD card']) for (const includeWww of [true, false]) {
+ test(`${language}: ${source} ${includeWww ? 'both images' : 'firmware-only'} progress stays visible with manual controls folded`, async () => {
+ await otaPage(language, 'manual-progress-card', async ({page, calls, state}) => {
+ await manualInput(page, source, includeWww);
+ const start = source === 'URL' ? 'otaFromUrl()' : 'otaFromFile()';
+ await page.locator(`[onclick="${start}"]`).click();
+ await page.waitForFunction(() => otaUpgrade?.phase === 'app');
+ await page.locator('#ota-manual > summary').click();
+ await page.evaluate(() => refreshOtaProgress());
+ assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false);
+ await expectIndependentProgress(page, true);
+ state.app = 'pending_reboot';
+ await page.evaluate(() => refreshOtaProgress());
+ if (includeWww) {
+ await expectIndependentProgress(page, true);
+ state.www = 'completed';
+ state.wwwBusy = false;
+ await page.evaluate(() => refreshOtaProgress());
+ }
+ await expectIndependentProgress(page, false);
+ const wwwStarts = calls.filter(call => call.endpoint === 'ota/www/start' || call.endpoint === 'ota/www/start_sdcard');
+ assert.equal(wwwStarts.length, includeWww ? 1 : 0);
+ assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1);
+ assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false);
+ });
+ });
+ }
+
test(`${language}: repeated SD WWW retries preserve the source until the successful write`, async () => {
await otaPage(language, 'manual-retry', async ({page, calls, state}) => {
await manualInput(page, 'SD card', true);
@@ -212,9 +275,54 @@ for (const language of ['zh-CN', 'en-US']) {
});
});
+ for (const includeWww of [true, false]) {
+ test(`${language}: delayed abort refresh leaves the next SD ${includeWww ? 'both-image' : 'firmware-only'} polling active`, async () => {
+ await otaPage(language, 'manual-delayed-abort', async ({page, calls, state, gate, reached}) => {
+ await manualInput(page, 'URL', false);
+ await page.locator('[onclick="otaFromUrl()"]').click();
+ // Hold the real metadata request made after acknowledging abort.
+ await page.route('**/api/v1/ota/server/get', async route => {
+ reached.resolve();
+ await gate.promise;
+ await route.fulfill({json: {code: 0, data: {url: 'https://ota.fixture.invalid'}}});
+ });
+ await page.evaluate(() => {
+ const originalAbort = abortOta;
+ abortOta = () => (window.otaTestAbort = originalAbort());
+ });
+ await page.locator('#ota-abort-btn').click();
+ await kit.confirmSheet(page);
+ await reached.promise;
+ await page.waitForFunction(() => otaUpgrade.phase === 'done');
+ await manualInput(page, 'SD card', includeWww);
+ await page.locator('[onclick="otaFromFile()"]').click();
+ await page.waitForFunction(() => otaUpgrade.phase === 'app' && refreshInterval !== null);
+ const nextTimer = await page.evaluate(() => refreshInterval);
+ gate.resolve();
+ await page.evaluate(() => window.otaTestAbort);
+ assert.equal(await page.evaluate(() => refreshInterval), nextTimer);
+ const wwwReply = includeWww ? page.waitForResponse(response => response.url().endsWith('/api/v1/ota/www/start_sdcard')) : null;
+ state.app = 'pending_reboot';
+ if (includeWww) {
+ await wwwReply;
+ await page.waitForFunction(() => otaUpgrade.phase === 'www');
+ assert.equal(calls.filter(call => call.endpoint === 'ota/www/start_sdcard').length, 1);
+ assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0);
+ state.www = 'completed';
+ state.wwwBusy = false;
+ }
+ await page.waitForFunction(() => document.getElementById('ota-state-text').textContent === t('ui.allUpgradeComplete'));
+ assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 1);
+ assert.equal(calls.filter(call => call.endpoint === 'ota/www/start_sdcard').length, includeWww ? 1 : 0);
+ });
+ });
+ }
+
test(`${language}: real OTA page keeps one owner while WWW download is slow`, async () => {
await otaPage(language, 'download-paused', async ({page, calls, reached, gate, state}) => {
await reached.promise;
+ assert.equal(await page.locator('#ota-manual').evaluate(section => section.open), false);
+ await expectIndependentProgress(page, false);
await page.waitForTimeout(1500);
assert.equal(calls.filter(call => call.endpoint === 'ota/www/start').length, 0);
assert.equal(calls.filter(call => call.endpoint === 'system/reboot').length, 0);
diff --git a/tests/prompts/ota.test.cjs b/tests/prompts/ota.test.cjs
index 29c1170..32d07df 100644
--- a/tests/prompts/ota.test.cjs
+++ b/tests/prompts/ota.test.cjs
@@ -399,6 +399,47 @@ test('manual abort releases the upgrade and allows the next attempt', async () =
assert.equal(d.count('system.reboot'), 1);
});
+for (const includeWww of [true, false]) {
+ test(`a delayed abort refresh cannot stop the next ${includeWww ? 'two-image' : 'firmware-only'} upgrade`, async () => {
+ const d = await device('zh-CN', includeWww);
+ const refresh = deferred(), reached = deferred();
+ d.h.ctx.abortRefresh = () => { reached.resolve(); return refresh.promise; };
+ d.h.run('confirmAction = async () => true; refreshOtaInfo = abortRefresh');
+ await d.h.run('otaFromUrl()');
+ const oldTimer = d.h.run('refreshInterval');
+ const abort = d.h.run('abortOta()');
+ await reached.promise;
+ assert.equal(d.h.timers.has(oldTimer), false);
+ await d.h.run('otaFromFile()');
+ const nextTimer = d.h.run('refreshInterval');
+ assert(d.h.timers.has(nextTimer));
+ refresh.resolve();
+ await abort;
+ assert.equal(d.h.run('refreshInterval'), nextTimer);
+ assert(d.h.timers.has(nextTimer));
+ d.state.app = 'pending_reboot';
+ await d.h.advance(1000);
+ if (includeWww) {
+ assert.equal(d.count('ota.www.start_sdcard'), 1);
+ assert.equal(d.count('system.reboot'), 0);
+ d.state.www = 'completed';
+ d.state.wwwBusy = false;
+ await d.h.advance(1000);
+ } else assert.equal(d.count('ota.www.start_sdcard'), 0);
+ assert.equal(d.count('system.reboot'), 1);
+ });
+}
+
+test('abort without a local attempt still stops the existing page polling', async () => {
+ const d = await device();
+ d.h.run('confirmAction = async () => true; refreshOtaInfo = async () => {}; refreshInterval = setInterval(refreshOtaProgress, 1000)');
+ const timer = d.h.run('refreshInterval');
+ await d.h.run('abortOta()');
+ assert.equal(d.count('ota.abort'), 1);
+ assert.equal(d.h.timers.has(timer), false);
+ assert.equal(d.h.run('refreshInterval'), null);
+});
+
test('WWW completion cannot reboot while the WWW writer is still running', async () => {
const d = await device();
await d.h.run('otaFromUrl()');