- `;
-
- otaStep = 'idle';
-
- // 触发设备重启
- try {
- requireApiSuccess(await api.call('system.reboot', { delay: 1 }), 'call');
- } catch (e) {
- showToast(t('promptRepair.rebootUnknown'), 'warning', 10000);
- }
-
- // 检测设备重启
- startRebootDetection();
+ await completeOtaUpgrade(upgrade);
} catch (error) {
console.error('Proxy OTA failed:', error);
- stateEl.textContent = typeof t === 'function' ? t('ui.upgradeFailed') : '升级失败';
const failure = { op: error.otaStage === 'www' ? 'ota.upgradewww' : 'ota.upgrade', error, endpoint: 'ota.proxy', write: true };
- messageEl.textContent = failureText(failure);
- progressBar.style.width = '0%';
progressPercent.textContent = '';
- otaStep = 'idle';
- reportFailure(failure);
+ showOtaUpgradeFailure(upgrade, failure);
}
}
diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js
index eb38be2..89fb19b 100644
--- a/components/ts_webui/web/js/lang/en-US.js
+++ b/components/ts_webui/web/js/lang/en-US.js
@@ -147,6 +147,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', {
"outputPreview": "Output: {output}",
"upgradeWritten": "Upgrade files were written. Requesting a device restart.",
"rebootUnknown": "Restart confirmation was not received. The device may be restarting; wait for the status check before repeating the operation.",
+ "otaBusy": "An upgrade or restart check is in progress. Wait for it to finish.",
+ "otaWrittenNoReboot": "Upgrade files were written, but the next boot could not be confirmed; no restart was requested.",
+ "otaRebootRefused": "Upgrade files were written, but the device refused the restart request.",
+ "otaConfirming": "Confirming the upgraded firmware...",
+ "otaRebootUnconfirmed": "Upgrade result unconfirmed",
+ "otaRebootMismatch": "The running firmware does not match the upgrade target",
+ "otaTargetDetails": "Expected version: {expected}; running version: {actual}. Check the device status.",
"horizontal": "Horizontal",
"vertical": "Vertical",
"diagonalRight": "Diagonal ↘",
@@ -3989,7 +3996,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', {
wifi: { scan: { failed: 'Failed to scan for WiFi networks' }, applyap: { failed: 'Failed to apply the hotspot settings', unknown: 'The result of applying the hotspot settings is unconfirmed' }, connect: { failed: 'Failed to connect to WiFi', unknown: 'The result of connecting to WiFi is unconfirmed' }, disconnect: { failed: 'Failed to disconnect from WiFi', unknown: 'The result of disconnecting from WiFi is unconfirmed' }, mode: { failed: 'Failed to change the WiFi mode', unknown: 'The result of changing the WiFi mode is unconfirmed' }, stations: { failed: 'Failed to read the list of connected devices' } },
cert: { status: { failed: 'Failed to refresh the certificate status' }, timesync: { failed: 'Failed to sync the device time', unknown: 'The result of syncing the device time is unconfirmed' }, keypair: { failed: 'Failed to generate the key pair', unknown: 'The result of generating the key pair is unconfirmed' }, csr: { failed: 'Failed to generate the certificate request (CSR)' }, install: { failed: 'Failed to install the certificate', unknown: 'The result of installing the certificate is unconfirmed' }, installca: { failed: 'Failed to install the CA chain', unknown: 'The result of installing the CA chain is unconfirmed' }, view: { failed: 'Failed to read the device certificate' }, delete: { failed: 'Failed to delete the PKI credentials', unknown: 'The result of deleting the PKI credentials is unconfirmed' } },
account: { login: { failed: 'Login failed' }, changepassword: { failed: 'Failed to change the password', unknown: 'The result of changing the password is unconfirmed' }, setroot: { failed: 'Failed to set the root password', unknown: 'The result of setting the root password is unconfirmed' }, setadmin: { failed: 'Failed to set the admin password', unknown: 'The result of setting the admin password is unconfirmed' }, resetadmin: { failed: 'Failed to reset the admin password', unknown: 'The result of resetting the admin password is unconfirmed' } },
- ota: { upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not start the upgrade from the SD card' }, abort: { failed: 'Failed to abort the upgrade', unknown: 'The result of aborting the upgrade is unconfirmed' }, validate: { failed: 'Failed to mark the firmware as valid', unknown: 'The result of marking the firmware as valid is unconfirmed' }, rollback: { failed: 'Failed to roll back the firmware', unknown: 'The result of rolling back the firmware is unconfirmed' }, saveserver: { failed: 'Failed to save the OTA server address', unknown: 'The result of saving the OTA server address is unconfirmed' } },
+ ota: { verify: { failed: 'Could not confirm the upgraded firmware', unknown: 'The result of the upgrade is unconfirmed' }, upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not start the upgrade from the SD card' }, abort: { failed: 'Failed to abort the upgrade', unknown: 'The result of aborting the upgrade is unconfirmed' }, validate: { failed: 'Failed to mark the firmware as valid', unknown: 'The result of marking the firmware as valid is unconfirmed' }, rollback: { failed: 'Failed to roll back the firmware', unknown: 'The result of rolling back the firmware is unconfirmed' }, saveserver: { failed: 'Failed to save the OTA server address', unknown: 'The result of saving the OTA server address is unconfirmed' } },
device: { reboot: { failed: 'Failed to restart the device', unknown: 'The result of restarting the device is unconfirmed' } },
led: { list: { failed: 'Failed to read the LED device list' }, clear: { failed: 'Failed to turn off the LED', unknown: 'The result of turning off the LED is unconfirmed' }, ccapply: { failed: 'Failed to apply the color correction', unknown: 'The result of applying the color correction is unconfirmed' }, ccreset: { failed: 'Failed to reset the color correction', unknown: 'The result of resetting the color correction is unconfirmed' }, ccimport: { failed: 'Failed to import the color correction config', unknown: 'The result of importing the color correction config is unconfirmed' }, filterapply: { failed: 'Failed to apply the filter', unknown: 'The result of applying the filter is unconfirmed' }, filterstop: { failed: 'Failed to stop the filter', unknown: 'The result of stopping the filter is unconfirmed' }, brightness: { failed: 'Failed to set the brightness of {device}', unknown: 'The result of setting the brightness of {device} is unconfirmed' }, saveconfig: { failed: 'Failed to save the LED config', unknown: 'The result of saving the LED config is unconfirmed' }, starteffect: { failed: 'Failed to start the LED animation', unknown: 'The result of starting the LED animation is unconfirmed' }, stopeffect: { failed: 'Failed to stop the LED animation', unknown: 'The result of stopping the LED animation is unconfirmed' }, fill: { failed: 'Failed to fill the LED with a color', unknown: 'The result of filling the LED with a color is unconfirmed' }, image: { failed: 'Failed to display the image on the LED', unknown: 'The result of displaying the image on the LED is unconfirmed' }, qr: { failed: 'Failed to show the QR code on the LED', unknown: 'The result of showing the QR code on the LED is unconfirmed' }, text: { failed: 'Failed to show the text on the LED', unknown: 'The result of showing the text on the LED is unconfirmed' }, stoptext: { failed: 'Failed to stop the text on the LED', unknown: 'The result of stopping the text on the LED is unconfirmed' }, toggle: { failed: 'Failed to switch the LED on or off', unknown: 'The result of switching the LED on or off is unconfirmed' } },
home: { quicklist: { failed: 'Failed to read the quick actions' }, quicklog: { failed: 'Failed to read the run log' } },
@@ -4142,6 +4149,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', {
old_password_wrong: { why: 'the current password is wrong', hint: 'Check it and try again.' },
root_required: { why: 'this action needs the root account', hint: 'Log in as root and try again.' },
ota_failed: { why: 'the device reported an error during the upgrade', hint: 'Click Details to see the reason the device gave.' },
+ ota_target_unavailable: { why: 'the device did not provide the next firmware information', hint: 'The files were written. Check the partition status before restarting.' },
+ ota_target_mismatch: { why: 'the running firmware version {actual} does not match the target version {expected}', hint: 'Check the upgrade partition and device status before deciding whether to upgrade again.' },
ota_no_rollback: { why: 'there is no older version to roll back to', hint: 'An older version only exists after an upgrade.' },
ota_server_not_set: { why: 'no OTA server address has been set', hint: 'Save a server address on the OTA page first, or enter a full address.' },
url_invalid: { why: 'the address is not valid, or it is too long', hint: 'Enter a complete, valid address.' },
diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js
index bf48389..0dfb307 100644
--- a/components/ts_webui/web/js/lang/zh-CN.js
+++ b/components/ts_webui/web/js/lang/zh-CN.js
@@ -147,6 +147,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', {
"outputPreview": "输出:{output}",
"upgradeWritten": "升级文件已写入,正在请求设备重启。",
"rebootUnknown": "未收到重启确认。设备可能正在重启,请等待状态检查,不要重复操作。",
+ "otaBusy": "正在升级或确认重启,请等待当前操作结束。",
+ "otaWrittenNoReboot": "升级文件已写入,重启前状态未确认;尚未请求重启。",
+ "otaRebootRefused": "升级文件已写入,但设备拒绝了重启请求。",
+ "otaConfirming": "正在确认升级后的固件...",
+ "otaRebootUnconfirmed": "升级结果未确认",
+ "otaRebootMismatch": "启动的固件与升级目标不符",
+ "otaTargetDetails": "目标版本:{expected};当前版本:{actual}。请检查设备状态。",
"horizontal": "横向",
"vertical": "纵向",
"diagonalRight": "对角 ↘",
@@ -3951,7 +3958,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', {
wifi: { scan: { failed: '扫描 WiFi 失败' }, applyap: { failed: '应用热点配置失败', unknown: '应用热点配置的结果未确认' }, connect: { failed: '连接 WiFi 失败', unknown: '连接 WiFi 的结果未确认' }, disconnect: { failed: '断开 WiFi 连接失败', unknown: '断开 WiFi 连接的结果未确认' }, mode: { failed: '切换 WiFi 模式失败', unknown: '切换 WiFi 模式的结果未确认' }, stations: { failed: '读取已连接设备列表失败' } },
cert: { status: { failed: '刷新证书状态失败' }, timesync: { failed: '同步设备时间失败', unknown: '同步设备时间的结果未确认' }, keypair: { failed: '生成密钥对失败', unknown: '生成密钥对的结果未确认' }, csr: { failed: '生成证书请求(CSR)失败' }, install: { failed: '安装证书失败', unknown: '安装证书的结果未确认' }, installca: { failed: '安装 CA 证书链失败', unknown: '安装 CA 证书链的结果未确认' }, view: { failed: '读取设备证书失败' }, delete: { failed: '删除 PKI 凭证失败', unknown: '删除 PKI 凭证的结果未确认' } },
account: { login: { failed: '登录失败' }, changepassword: { failed: '修改密码失败', unknown: '修改密码的结果未确认' }, setroot: { failed: '设置 root 密码失败', unknown: '设置 root 密码的结果未确认' }, setadmin: { failed: '设置 admin 密码失败', unknown: '设置 admin 密码的结果未确认' }, resetadmin: { failed: '重置 admin 密码失败', unknown: '重置 admin 密码的结果未确认' } },
- ota: { upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '无法开始从 SD 卡升级' }, abort: { failed: '中止升级失败', unknown: '中止升级的结果未确认' }, validate: { failed: '标记固件有效失败', unknown: '标记固件有效的结果未确认' }, rollback: { failed: '回滚固件失败', unknown: '回滚固件的结果未确认' }, saveserver: { failed: '保存 OTA 服务器地址失败', unknown: '保存 OTA 服务器地址的结果未确认' } },
+ ota: { verify: { failed: '无法确认升级后的固件', unknown: '升级结果未确认' }, upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '无法开始从 SD 卡升级' }, abort: { failed: '中止升级失败', unknown: '中止升级的结果未确认' }, validate: { failed: '标记固件有效失败', unknown: '标记固件有效的结果未确认' }, rollback: { failed: '回滚固件失败', unknown: '回滚固件的结果未确认' }, saveserver: { failed: '保存 OTA 服务器地址失败', unknown: '保存 OTA 服务器地址的结果未确认' } },
device: { reboot: { failed: '重启设备失败', unknown: '重启设备的结果未确认' } },
led: { list: { failed: '读取 LED 设备列表失败' }, clear: { failed: '关闭 LED 失败', unknown: '关闭 LED 的结果未确认' }, ccapply: { failed: '应用色彩校正失败', unknown: '应用色彩校正的结果未确认' }, ccreset: { failed: '重置色彩校正失败', unknown: '重置色彩校正的结果未确认' }, ccimport: { failed: '导入色彩校正配置失败', unknown: '导入色彩校正配置的结果未确认' }, filterapply: { failed: '应用滤镜失败', unknown: '应用滤镜的结果未确认' }, filterstop: { failed: '停止滤镜失败', unknown: '停止滤镜的结果未确认' }, brightness: { failed: '设置 {device} 的亮度失败', unknown: '设置 {device} 亮度的结果未确认' }, saveconfig: { failed: '保存 LED 配置失败', unknown: '保存 LED 配置的结果未确认' }, starteffect: { failed: '启动 LED 动画失败', unknown: '启动 LED 动画的结果未确认' }, stopeffect: { failed: '停止 LED 动画失败', unknown: '停止 LED 动画的结果未确认' }, fill: { failed: '填充 LED 颜色失败', unknown: '填充 LED 颜色的结果未确认' }, image: { failed: '在 LED 上显示图像失败', unknown: '在 LED 上显示图像的结果未确认' }, qr: { failed: '在 LED 上显示 QR 码失败', unknown: '在 LED 上显示 QR 码的结果未确认' }, text: { failed: '在 LED 上显示文本失败', unknown: '在 LED 上显示文本的结果未确认' }, stoptext: { failed: '停止 LED 上的文本失败', unknown: '停止 LED 上的文本的结果未确认' }, toggle: { failed: '切换 LED 开关失败', unknown: '切换 LED 开关的结果未确认' } },
home: { quicklist: { failed: '读取快捷操作失败' }, quicklog: { failed: '读取运行日志失败' } },
@@ -4104,6 +4111,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', {
old_password_wrong: { why: '当前密码不对', hint: '请检查后重新输入。' },
root_required: { why: '这项操作需要 root 账户', hint: '请换用 root 账户登录后再操作。' },
ota_failed: { why: '设备在升级过程中报告了错误', hint: '点“详情”可以看到设备给出的原因。' },
+ ota_target_unavailable: { why: '设备未能提供待启动固件的信息', hint: '文件已经写入,请检查分区状态后再重启。' },
+ ota_target_mismatch: { why: '当前固件版本 {actual} 与目标版本 {expected} 不符', hint: '请检查升级分区和设备状态,再决定是否重新升级。' },
ota_no_rollback: { why: '没有可以回滚的旧版本', hint: '只有升级过之后,才会有旧版本可以回滚。' },
ota_server_not_set: { why: '还没有设置 OTA 服务器地址', hint: '请先在 OTA 页保存服务器地址,或直接填写完整的地址。' },
url_invalid: { why: '地址的格式不对,或者太长了', hint: '请填写完整、正确的地址。' },
diff --git a/tests/prompts/ota.browser.test.cjs b/tests/prompts/ota.browser.test.cjs
new file mode 100644
index 0000000..f51aa36
--- /dev/null
+++ b/tests/prompts/ota.browser.test.cjs
@@ -0,0 +1,259 @@
+const {test} = require('node:test');
+const assert = require('node:assert/strict');
+const kit = require('./pagekit.cjs');
+
+kit.install();
+const deferred = () => {
+ let resolve;
+ const promise = new Promise(done => { resolve = done; });
+ return {promise, resolve};
+};
+
+async function otaPage(language, mode, run) {
+ const target = {label: 'ota_1', address: 0x360000, size: 0x340000, version: '0.6.2+target', project: 'TianShanOS', is_bootable: true};
+ const state = {app: 'idle', www: 'idle', wwwStart: 0, firmwareWritten: false, wwwWritten: false, wwwBusy: false,
+ running: {...target, label: 'ota_0', address: 0x20000, version: '0.6.1+old'}};
+ const respond = call => {
+ switch (call.endpoint) {
+ case 'ota/server/get': return {code: 0, data: {url: 'https://ota.fixture.invalid'}};
+ case 'ota/version': return {code: 0, data: {version: state.running.version, project: 'TianShanOS', compile_date: 'Oct 1 2026', compile_time: '00:00:00'}};
+ case 'ota/partitions': return {code: 0, data: {running: state.running, next: target}};
+ case 'ota/test_connection': return {code: 0, data: {dns_ok: true, tcp_ok: true, http_ok: true}};
+ case 'ota/progress': return {code: 0, data: {state: state.app, percent: 100}};
+ case 'ota/www/start':
+ case 'ota/www/start_sdcard':
+ if (state.wwwStart) return {code: state.wwwStart, message: 'WWW start refused'};
+ state.wwwBusy = true;
+ state.www = 'writing';
+ return {code: 0, data: {status: 'started'}};
+ case 'ota/www/progress': return {code: 0, data: {state: state.www, running: state.wwwBusy, percent: 20}};
+ case 'ota/upgrade_url':
+ case 'ota/upgrade_file': state.app = 'writing'; return {code: 0, data: {status: 'started'}};
+ case 'storage/info': return {code: 0, data: {type: 'file'}};
+ case 'ota/abort': state.app = 'idle'; return {code: 0};
+ case 'ota/firmware':
+ state.firmwareWritten = true;
+ state.app = 'pending_reboot';
+ return {status: 'success'};
+ case 'ota/www':
+ if (mode === 'upload-drop') return kit.DROP;
+ if (state.wwwBusy) return {status: 'error', error: 'OTA already in progress'};
+ state.wwwWritten = true;
+ return {status: 'success'};
+ case 'system/reboot':
+ if (mode !== 'old-firmware') state.running = {...target};
+ if (mode === 'wrong-build') state.running.version = '0.6.2+other';
+ return {code: 0, data: {status: 'rebooting'}};
+ }
+ };
+ await kit.withPage(language, respond, async d => {
+ const gate = deferred(), reached = deferred();
+ await d.page.route('https://ota.fixture.invalid/**', async route => {
+ const url = route.request().url();
+ if (url.endsWith('/version')) {
+ await route.fulfill({json: {version: target.version, compile_date: 'Oct 13 2026', compile_time: '00:00:00', size: 32}});
+ return;
+ }
+ if (mode === 'download-paused' && url.endsWith('/www.bin')) {
+ reached.resolve();
+ await gate.promise;
+ }
+ await route.fulfill({body: Buffer.alloc(32), contentType: 'application/octet-stream', headers: {'Content-Length': '32'}});
+ });
+ await d.page.evaluate(() => {
+ api.token = 'fixture'; api.username = 'root'; api.level = 2;
+ closeLoginModal();
+ router.navigate('/ota');
+ });
+ await d.page.waitForSelector('#ota-server-input');
+ await d.page.locator('#ota-server-input').fill('https://ota.fixture.invalid');
+ if (!mode.startsWith('manual-')) {
+ await d.page.locator('[onclick="checkForUpdates()"]').click();
+ await d.page.locator('[onclick="upgradeFromServer()"]').click();
+ }
+ try { await run({...d, state, gate, reached}); }
+ finally { gate.resolve(); }
+ });
+}
+
+async function manualInput(page, source, includeWww) {
+ await page.waitForFunction(() => document.getElementById('ota-current-version')?.textContent.includes('0.6.'));
+ await page.locator('#ota-manual').evaluate(section => { section.open = true; });
+ const prefix = source === 'URL' ? 'ota-url' : 'ota-file';
+ await page.locator(`#${prefix}-input`).fill(source === 'URL'
+ ? 'https://ota.fixture.invalid/firmware.bin' : '/sdcard/update/TianShanOS.bin');
+ await page.locator(`#${prefix}-include-www`).setChecked(includeWww);
+}
+
+async function leaveOtaPage(page) {
+ await page.evaluate(() => {
+ router.register('/ota-test-away', async () => {
+ document.getElementById('page-content').innerHTML = '