Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified build/TianShanOS.bin
Binary file not shown.
Binary file modified build/bootloader/bootloader.bin
Binary file not shown.
12 changes: 6 additions & 6 deletions build/firmware-manifest.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,7 +23,7 @@
},
"bootloader/bootloader.bin": {
"bytes": 22496,
"sha256": "3f2254ead6037716cf2c7d2b6ac9ed8414b87779ea64ee52c63282274fbf0f24"
"sha256": "4fa4a1b8b644d2291bbf38109e3197450c4d754a152548a9262de24a39417d6d"
},
"partition_table/partition-table.bin": {
"bytes": 3072,
Expand Down
Binary file modified build/www.bin
Binary file not shown.
33 changes: 18 additions & 15 deletions components/ts_webui/web/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13004,6 +13004,17 @@ async function loadOtaPage() {
<div class="page page-ota">
<div class="ota-col">
<h1 class="t-title">${t('ota.firmwareUpgrade')}</h1>

<!-- 升级进度区(动态显示) -->
<div class="card" id="ota-progress-section" style="display:none">
<h2 class="t-section" style="margin-bottom:12px">${t('ota.upgradeProgress')}</h2>
<div class="ota-prog">
<div class="bar" style="flex:1"><i id="ota-progress-bar" style="width:0%"></i></div>
<span class="t-value num" id="ota-progress-percent">0%</span>
<button class="btn sm dg" id="ota-abort-btn" onclick="abortOta()"><svg class="i"><use href="#ri-stop-line"/></svg>${t('ota.abort')}</button>
</div>
<div class="t-note ota-prog-note"><span id="ota-state-text">${t('ota.preparing')}</span> · <span id="ota-progress-size">0 / 0</span> <span id="ota-message"></span></div>
</div>

<!-- 核心信息区:版本 + OTA 服务器 -->
<div class="card">
Expand Down Expand Up @@ -13057,16 +13068,6 @@ async function loadOtaPage() {
<label><input type="checkbox" id="ota-file-include-www" checked>${t('ota.includeWww')}</label>
</div>
</div>
<!-- 升级进度区(动态显示) -->
<div id="ota-progress-section" style="display:none">
<hr class="sep" style="margin-bottom:12px">
<div class="ota-prog">
<div class="bar" style="flex:1"><i id="ota-progress-bar" style="width:0%"></i></div>
<span class="t-value num" id="ota-progress-percent">0%</span>
<button class="btn sm dg" id="ota-abort-btn" onclick="abortOta()"><svg class="i"><use href="#ri-stop-line"/></svg>${t('ota.abort')}</button>
</div>
<div class="t-note ota-prog-note"><span id="ota-state-text">${t('ota.preparing')}</span> · <span id="ota-progress-size">0 / 0</span> <span id="ota-message"></span></div>
</div>
</div>
</details>
</div>
Expand Down Expand Up @@ -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 + '%';
Expand Down Expand Up @@ -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%';
Expand Down Expand Up @@ -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%';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 = '';
Expand Down
1 change: 1 addition & 0 deletions components/ts_webui/web/js/lang/en-US.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions components/ts_webui/web/js/lang/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', {
downloading: '下载中',
installing: '安装中',
progress: '进度',
upgradeProgress: '升级进度',
upgradeSuccess: '升级成功,即将重启...',
upgradeFailed: '升级失败',
rollback: '回滚',
Expand Down
108 changes: 108 additions & 0 deletions tests/prompts/ota.browser.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions tests/prompts/ota.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()');
Expand Down
Loading