diff --git a/src/components/navbar/index.vue b/src/components/navbar/index.vue index 5ea15f5c..e32f2ff8 100644 --- a/src/components/navbar/index.vue +++ b/src/components/navbar/index.vue @@ -175,7 +175,7 @@ import useLocale from '@/hooks/locale'; import useUser from '@/hooks/user'; import Menu from '@/components/menu/index.vue'; - import { connect, disconnect, eeprom_init } from '@/utils/serial.js'; + import { connect, disconnect, eeprom_init, rtc_sync_beijing } from '@/utils/serial.js'; import { useI18n } from 'vue-i18n'; const { t } = useI18n(); const drivers = import.meta.glob('@/drivers/*.json', { eager: true }); @@ -232,6 +232,7 @@ "LOSEHU.*P.*K" : "ltsk.json", "LOSEHU.*P.*" : "lts.json", "LOSEHU.*D" : "losehud.json", + "UVE.*" : "losehu124h.json", "LOSEHU13[0-9].*HS" : "losehu124h.json", "LOSEHU13[0-9].*H" : "losehu124h.json", "LOSEHU13[0-9].*KS" : "losehu120k.json", @@ -307,6 +308,12 @@ } }) + // Auto sync device RTC to current Beijing time on connect (best effort). + // Only do this for UVE builds to avoid slowing down other firmwares. + if (/^UVE/i.test(version)) { + try { await rtc_sync_beijing(_connect); } catch {} + } + appStore.updateSettings({ connectState: true, firmwareVersion: version, configuration: _configuration }); }else{ await disconnect(appStore.connectPort); diff --git a/src/store/modules/app/index.ts b/src/store/modules/app/index.ts index c452e217..5743be5f 100644 --- a/src/store/modules/app/index.ts +++ b/src/store/modules/app/index.ts @@ -16,6 +16,9 @@ const useAppStore = defineStore('app', { appDevice(state: AppState) { return state.device; }, + isUve5(state: AppState): boolean { + return typeof state.firmwareVersion === 'string' && state.firmwareVersion.startsWith('UVE'); + }, appAsyncMenus(state: AppState): RouteRecordNormalized[] { return state.serverMenu as unknown as RouteRecordNormalized[]; }, diff --git a/src/store/modules/app/types.ts b/src/store/modules/app/types.ts index 4229f837..624248ab 100644 --- a/src/store/modules/app/types.ts +++ b/src/store/modules/app/types.ts @@ -16,5 +16,12 @@ export interface AppState { tabBar: boolean; menuFromServer: boolean; serverMenu: RouteRecordNormalized[]; + + // Device connection/runtime info (set on successful handshake in navbar connect) + connectState?: boolean; + connectPort?: unknown; + firmwareVersion?: string; + configuration?: unknown; + [key: string]: unknown; } diff --git a/src/utils/serial.js b/src/utils/serial.js index 32a4039f..22136813 100644 --- a/src/utils/serial.js +++ b/src/utils/serial.js @@ -755,7 +755,12 @@ function globalRelease(target = 'all'){ if(globalWriteReader)globalWriteReader.releaseLock() } if(target != 'write'){ - if(globalReadReader)globalReadReader.releaseLock() + // If a previous read is still pending, releaseLock() may throw. + // Best-effort cancel first to ensure the stream unlocks. + if (globalReadReader) { + try { globalReadReader.cancel(); } catch {} + try { globalReadReader.releaseLock(); } catch {} + } } } catch {} } @@ -775,8 +780,10 @@ async function connect() { return null; } + const baudRate = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 38400; + try { - await port.open({ baudRate: 38400 }); + await port.open({ baudRate }); return port; } catch (error) { if(port.connected && port.readable && port.writable && !port.readable.locked && !port.writable.locked){ @@ -801,6 +808,233 @@ async function disconnect(port) { console.error('Error closing the serial port:', error); } } +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(data) { + let crc = 0xFFFFFFFF; + for (let i = 0; i < data.length; i++) { + crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +async function rawWrite(port, bytes) { + if (!port || !port.writable) { + throw new Error('Serial port not writable'); + } + globalRelease('write'); + const writer = port.writable.getWriter(); + globalWriteReader = writer; + try { + const CHUNK = 256; + for (let offset = 0; offset < bytes.length; offset += CHUNK) { + await writer.write(bytes.slice(offset, offset + CHUNK)); + } + } finally { + try { writer.releaseLock(); } catch {} + } +} + +async function rawReadOnce(port, timeoutMs) { + if (!port || !port.readable) { + throw new Error('Serial port not readable'); + } + globalRelease('read'); + const reader = port.readable.getReader(); + globalReadReader = reader; + let timeoutId; + + try { + const result = await Promise.race([ + reader.read(), + new Promise((resolve) => { + timeoutId = setTimeout(async () => { + // Do not await cancel(): it may hang when the device/USB stack is in a bad state. + try { reader.cancel(); } catch {} + resolve({ value: undefined, done: false, timeout: true }); + }, timeoutMs); + }) + ]); + if (result && result.done) { + throw new Error('Serial stream closed'); + } + return result?.value; + } finally { + clearTimeout(timeoutId); + try { reader.releaseLock(); } catch {} + } +} + +async function waitForText(port, needle, timeoutMs, onChunk) { + const decoder = new TextDecoder(); + let buffer = ''; + const deadline = Date.now() + timeoutMs; + const needles = Array.isArray(needle) ? needle : [needle]; + + while (Date.now() < deadline) { + const remaining = Math.max(1, deadline - Date.now()); + const chunk = await rawReadOnce(port, Math.min(500, remaining)); + if (!chunk || chunk.length === 0) { + continue; + } + const text = decoder.decode(chunk); + if (onChunk) onChunk(text); + buffer += text; + if (needles.some((n) => buffer.includes(n))) { + return true; + } + if (buffer.length > 4096) { + buffer = buffer.slice(-4096); + } + } + throw new Error(`Timeout waiting for ${needles.join(' | ')}`); +} + +async function readAck(port, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remaining = Math.max(1, deadline - Date.now()); + const chunk = await rawReadOnce(port, Math.min(500, remaining)); + if (!chunk || chunk.length === 0) continue; + + for (let i = 0; i < chunk.length; i++) { + const b = chunk[i]; + if (b === 0x41) { + return { ok: true }; + } + if (b === 0x45) { + let code; + if (i + 1 < chunk.length) { + code = chunk[i + 1]; + } else { + const extra = await rawReadOnce(port, 200); + if (extra && extra.length) code = extra[0]; + } + return { ok: false, code }; + } + } + } + return { ok: false, timeout: true }; +} + +async function uve5_flashFirmware(port, firmware, options = {}) { + const { + chunkSize = 2048, + readyTimeoutMs = 30000, + startTimeoutMs = 60000, + ackTimeoutMs = 15000, + retries = 30, + onLog, + onProgress + } = options; + + const log = (msg) => { + if (onLog) onLog(msg); + }; + + const reportProgress = (sent) => { + if (onProgress) onProgress(sent, firmware.length); + }; + + if (!firmware || firmware.length === 0) { + throw new Error('Empty firmware'); + } + if (chunkSize <= 0 || chunkSize > 0xFFFF) { + throw new Error('Invalid chunkSize'); + } + + const MAGIC = 0x32445055; + const header = new Uint8Array(10); + const headerDv = new DataView(header.buffer); + headerDv.setUint32(0, MAGIC >>> 0, true); + headerDv.setUint32(4, firmware.length >>> 0, true); + headerDv.setUint16(8, chunkSize & 0xFFFF, true); + + // Handshake can be flaky on some browsers/USB-UARTs; retry the READY→GO→header→START phase. + const handshakeRetries = 3; + let handshakeOk = false; + for (let hs = 1; hs <= handshakeRetries; hs += 1) { + log(`UVE5: 等待设备 READY... (${hs}/${handshakeRetries})`); + await waitForText(port, 'READY', readyTimeoutMs); + + log('UVE5: 发送 GO...'); + await rawWrite(port, new TextEncoder().encode('GO\n')); + // Give bootloader a moment to switch from READY spam into header parser. + await sleep(50); + + log('UVE5: 发送头信息...'); + await rawWrite(port, header); + // Some devices need a tiny gap after header before responding START. + await sleep(20); + + log('UVE5: 等待设备 START...'); + try { + // 新版 bootloader 会发 STRT(避免包含 'A' 导致误判 ACK)。 + // 兼容旧版仍可能发 START。 + await waitForText(port, ['STRT', 'START'], startTimeoutMs); + handshakeOk = true; + break; + } catch (e) { + if (hs >= handshakeRetries) throw e; + log('UVE5: 等待 START 超时,重试握手...'); + await sleep(200); + } + } + if (!handshakeOk) { + throw new Error('UVE5: 握手失败'); + } + + let seq = 0; + let offset = 0; + reportProgress(0); + + while (offset < firmware.length) { + const len = Math.min(chunkSize, firmware.length - offset); + const data = firmware.slice(offset, offset + len); + const crc = crc32(data); + + const frame = new Uint8Array(4 + 2 + len + 4); + const dv = new DataView(frame.buffer); + dv.setUint32(0, seq >>> 0, true); + dv.setUint16(4, len & 0xFFFF, true); + frame.set(data, 6); + dv.setUint32(6 + len, crc >>> 0, true); + + let attempt = 0; + while (true) { + await rawWrite(port, frame); + const perChunkTimeout = seq === 0 ? Math.max(ackTimeoutMs, 15000) : ackTimeoutMs; + const ack = await readAck(port, perChunkTimeout); + if (ack.ok) break; + attempt += 1; + const code = ack.code !== undefined ? ack.code : '?'; + log(`UVE5: 块${seq} NAK(E${code}),重试 ${attempt}/${retries}`); + if (attempt >= retries) { + throw new Error(`UVE5: 块${seq} 连续失败(E${code}),已放弃`); + } + // If device is still waiting for the previous frame to finish timing out, + // a tiny delay helps prevent piling up bytes. + await sleep(50); + } + + offset += len; + seq += 1; + reportProgress(offset); + } + + log('UVE5: 数据发送完成'); + reportProgress(firmware.length); +} function xor(data) { @@ -1134,6 +1368,64 @@ async function eeprom_init(port) { return decoder.decode(version.slice(0, version.indexOf(0))); } +function getBeijingDateParts(date = new Date()) { + const dtf = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = dtf.formatToParts(date); + const map = {}; + for (const p of parts) { + if (p.type !== 'literal') map[p.type] = p.value; + } + return { + year: Number(map.year), + month: Number(map.month), + day: Number(map.day), + hour: Number(map.hour), + minute: Number(map.minute), + second: Number(map.second), + }; +} + +// Sync RTC on connect: send current Beijing time to device. +// Firmware command: +// 0x0610 (len=8): uint16 year, uint8 month,day,hour,minute,second, uint8 flags +// Reply: +// 0x0611 (len=1): uint8 status (0=OK) +async function rtc_sync_beijing(port, { timeout = 600 } = {}) { + sessionStorage.removeItem('webusb') + const { year, month, day, hour, minute, second } = getBeijingDateParts(); + const year_l = year & 0xff; + const year_h = (year >> 8) & 0xff; + const flags = 0x01; + const packet = new Uint8Array([ + 0x10, 0x06, // ID 0x0610 + 0x08, 0x00, // len=8 + year_l, year_h, + month & 0xff, + day & 0xff, + hour & 0xff, + minute & 0xff, + second & 0xff, + flags, + ]); + await sendPacket(port, packet); + try { + const resp = await readPacket(port, 0x11, timeout); + return resp && resp.length >= 5 ? resp[4] : 0xff; + } catch (e) { + console.warn('rtc_sync_beijing: no reply (ignored)', e); + return null; + } +} + async function eeprom_read(port, address, size = 0x80, protocol = "official") { sessionStorage.removeItem('webusb') if (protocol == "official") { @@ -1175,6 +1467,63 @@ async function eeprom_read(port, address, size = 0x80, protocol = "official") { } } +// Shared flash read/write (ESP32 shared partition window) +// Command format is intentionally the same as the losehu extended EEPROM commands, +// but with command IDs 0x142B / 0x1438. +// Note: address here is the shared-partition offset (NOT the 0x02000-mapped logical EEPROM address). +// UVE5 default partition table uses a 512KiB shared partition: 0x00000..0x7FFFF. +const SHARED_PARTITION_SIZE = 0x80000; + +function assertSharedRange(address, size) { + if (!Number.isFinite(address) || address < 0) { + throw new Error('shared address must be a non-negative number'); + } + if (!Number.isFinite(size) || size < 0) { + throw new Error('shared size must be a non-negative number'); + } + const end = address + size; + if (end > SHARED_PARTITION_SIZE) { + throw new Error(`shared access out of range: 0x${address.toString(16)}..0x${(end - 1).toString(16)} (max 0x${(SHARED_PARTITION_SIZE - 1).toString(16)})`); + } +} +async function shared_read(port, address, size = 0x80) { + sessionStorage.removeItem('webusb') + + assertSharedRange(address, size); + + const address_msb = (address & 0xff00) >> 8; + const address_lsb = address & 0xff; + + const address_msb_h = (address & 0xff000000) >> 24; + const address_lsb_h = (address & 0xff0000) >> 16; + + const packet = new Uint8Array([0x2b, 0x14, 0x08, 0x00, address_lsb_h, address_msb_h, size, 0x00, 0xff, 0xff, 0xff, 0xff, address_lsb, address_msb]); + await sendPacket(port, packet); + + const response = await readPacket(port, 0x1c); + const data = new Uint8Array(response.slice(8)); + return data; +} + +async function shared_write(port, address, input, size = 0x80) { + assertSharedRange(address, size); + + const address_msb = (address & 0xff00) >> 8; + const address_lsb = address & 0xff; + + const address_msb_h = (address & 0xff000000) >> 24; + const address_lsb_h = (address & 0xff0000) >> 16; + + const packet = new Uint8Array([0x38, 0x14, 0x1c, 0x00, address_lsb_h, address_msb_h, size + 2, 0x00, 0xff, 0xff, 0xff, 0xff, address_lsb, address_msb]); + const mergedArray = new Uint8Array(packet.length + input.length); + mergedArray.set(packet); + mergedArray.set(input, packet.length); + + await sendPacket(port, mergedArray); + await readPacket(port, 0x1e); + return true; +} + async function eeprom_write(port, address, input, size = 0x80, protocol = "official") { if (protocol == "official") { // packet format: uint16 ID, uint16 length, uint16 address, uint8 size, uint8 padding, uint32 timestamp @@ -1591,12 +1940,16 @@ export { eeprom_reboot, check_eeprom, eeprom_write, + shared_read, + shared_write, flash_flashFirmware, flash_generateCommand, flash_generateK1Command, unpackVersion, unpack, readPacketNoVerify, + uve5_flashFirmware, sendSMSPacket, - readSMSPacket -} \ No newline at end of file + readSMSPacket, + rtc_sync_beijing +} diff --git a/src/views/list/flash/index.vue b/src/views/list/flash/index.vue index eb58e4b4..7015841f 100644 --- a/src/views/list/flash/index.vue +++ b/src/views/list/flash/index.vue @@ -7,18 +7,27 @@
- {{ state.binaryFile ? state.binaryName : $t('tool.selectFirmware') }} - {{ $t('tool.flash') }} + {{ state.binaryFile ? state.binaryName : $t('tool.selectFirmware') }} + {{ $t('tool.flash') }}
Official K1 + UVE5
+
+
+
进度条
+
{{ state.progress.toFixed(1) }}%
+
+ +
{{ state.phase }}
+
@@ -31,7 +40,7 @@ import { reactive, nextTick, onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { useAppStore } from '@/store'; -import { disconnect, connect, readPacket, sendPacket, unpackVersion, unpack, flash_generateCommand, readPacketNoVerify, flash_generateK1Command } from '@/utils/serial.js'; +import { disconnect, connect, readPacket, sendPacket, unpackVersion, unpack, flash_generateCommand, readPacketNoVerify, flash_generateK1Command, uve5_flashFirmware } from '@/utils/serial.js'; import { DialogPlugin } from 'tdesign-vue-next'; const appStore = useAppStore(); @@ -40,12 +49,18 @@ const state : { status: any, binaryName: any, binaryFile: any, - protocol: string + protocol: string, + progress: number, + phase: string, + isFlashing: boolean } = reactive({ status: "点击更新按钮更新固件到设备

", binaryFile: undefined, binaryName: '', - protocol: 'Official' + protocol: 'Official', + progress: 0, + phase: '', + isFlashing: false }) const route = useRoute(); @@ -100,10 +115,77 @@ const flashIt = async () => { alert('请选择文件'); return; } + + // Throttle status DOM updates to avoid UI stalls during high-frequency logs. + const statusMaxChars = 200_000; + let statusPending: string[] = []; + let statusFlushTimer: number | null = null; + const flushStatus = () => { + statusFlushTimer = null; + if (!statusPending.length) return; + state.status = state.status + statusPending.join('
') + '
'; + statusPending = []; + if (typeof state.status === 'string' && state.status.length > statusMaxChars) { + state.status = state.status.slice(-Math.floor(statusMaxChars * 0.75)); + } + nextTick(()=>{ + const textarea = document?.getElementById('statusArea'); + if(textarea)textarea.scrollTop = textarea?.scrollHeight; + }) + } + const appendStatus = (line: string) => { + statusPending.push(line); + if (statusFlushTimer === null) { + statusFlushTimer = window.setTimeout(flushStatus, 50); + } + } + + if (state.isFlashing) return; + state.isFlashing = true; + state.progress = 0; + state.phase = ''; + if(appStore.connectPort){ await disconnect(appStore.connectPort); } - let _connect = await connect(); + + const baudRate = state.protocol === 'UVE5' ? 115200 : 38400; + let _connect: any = null; + try { + _connect = await connect(baudRate); + if(!_connect){ + appendStatus('串口连接失败'); + return; + } + + if(state.protocol === 'UVE5'){ + state.phase = '刷写中...'; + appendStatus('UVE5:开始刷写(115200bps)'); + let lastLoggedPctInt = -1; + await uve5_flashFirmware(_connect, state.binaryFile, { + onLog: (msg: string) => { + appendStatus(msg); + }, + onProgress: (sent: number, total: number) => { + const pct = total > 0 ? (sent / total) * 100 : 0; + const pctFixed = Math.min(100, Math.max(0, Number(pct.toFixed(1)))); + state.progress = pctFixed; + state.phase = `刷写中... ${pctFixed.toFixed(1)}%`; + + // 避免刷屏:只在整数百分比变化时输出一次(以及 0%/100%) + const pctInt = Math.floor(pctFixed); + if (pctInt !== lastLoggedPctInt || pctInt === 0 || pctInt === 100) { + lastLoggedPctInt = pctInt; + appendStatus(`更新进度 ${pctFixed.toFixed(1)}%`); + } + } + }) + state.progress = 100; + state.phase = '完成'; + appendStatus('UVE5:固件刷写完成'); + return; + } + if(state.protocol == 'Official'){ await readPacket(_connect, 0x18, 1000); } @@ -150,20 +232,26 @@ const flashIt = async () => { return Promise.reject(e); } - state.status = state.status + `更新进度 ${((i / firmware.length) * 100).toFixed(1)}%
` - nextTick(()=>{ - const textarea = document?.getElementById('statusArea'); - if(textarea)textarea.scrollTop = textarea?.scrollHeight; - }) + appendStatus(`更新进度 ${((i / firmware.length) * 100).toFixed(1)}%`) + state.progress = Math.min(100, Math.max(0, Number((((i / firmware.length) * 100)).toFixed(1)))); + } + appendStatus("更新进度 100.0%"); + state.progress = 100; + appendStatus("固件更新成功"); + flushStatus(); + } + catch(e: any){ + state.phase = '失败'; + appendStatus(`${state.protocol}:失败 - ${e?.message ?? e}`); + flushStatus(); + } + finally { + try { + if (_connect) await disconnect(_connect); + } catch {} + appStore.updateSettings({ connectState: false }); + state.isFlashing = false; } - state.status = state.status + "更新进度 100.0%
"; - state.status = state.status + "固件更新成功"; - nextTick(()=>{ - const textarea = document?.getElementById('statusArea'); - if(textarea)textarea.scrollTop = textarea?.scrollHeight; - }) - disconnect(_connect); - appStore.updateSettings({ connectState: false }); } diff --git a/src/views/list/image/index.vue b/src/views/list/image/index.vue index fc484df4..ec8045ae 100644 --- a/src/views/list/image/index.vue +++ b/src/views/list/image/index.vue @@ -33,7 +33,7 @@ import { reactive, onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { useAppStore } from '@/store'; -import { eeprom_write, eeprom_reboot, eeprom_init, disconnect } from '@/utils/serial.js'; +import { eeprom_write, eeprom_reboot, eeprom_init, shared_write, disconnect } from '@/utils/serial.js'; const appStore = useAppStore(); @@ -226,12 +226,18 @@ const flashIt = async () => { return; } state.loading = true + const isUveGb = appStore.firmwareVersion?.startsWith('UVE') && appStore.configuration?.charset == "gb2312"; let position = 0x1E350; - if(appStore.configuration?.charset == "gb2312")position = 0x2080; + if(appStore.configuration?.charset == "gb2312")position = isUveGb ? 0x0080 : 0x2080; await eeprom_init(appStore.connectPort); const rawEEPROM = state.binaryFile; for (let i = position; i < rawEEPROM.length + position; i += 0x40) { - await eeprom_write(appStore.connectPort, i, rawEEPROM.slice(i - position, i - position + 0x40), rawEEPROM.slice(i - position, i - position + 0x40).length, appStore.configuration?.uart); + const chunk = rawEEPROM.slice(i - position, i - position + 0x40); + if(isUveGb){ + await shared_write(appStore.connectPort, i, chunk, chunk.length); + }else{ + await eeprom_write(appStore.connectPort, i, chunk, chunk.length, appStore.configuration?.uart); + } } await eeprom_reboot(appStore.connectPort); state.loading = false diff --git a/src/views/list/sat2/index.vue b/src/views/list/sat2/index.vue index d540a633..6918c954 100644 --- a/src/views/list/sat2/index.vue +++ b/src/views/list/sat2/index.vue @@ -68,12 +68,16 @@