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 @@