From f6132c614784ec0f32f3ec19b213f9740ab08067 Mon Sep 17 00:00:00 2001 From: taichunmin Date: Fri, 1 Nov 2024 12:40:15 +0800 Subject: [PATCH] Fix bluefy requestDevice and DFU --- pug/src/dfu.pug | 9 +- src/ChameleonUltra.ts | 226 +++++++++++++++----------------- src/plugin/SerialPortAdapter.ts | 11 +- src/plugin/WebbleAdapter.ts | 48 +++---- src/plugin/WebserialAdapter.ts | 4 +- 5 files changed, 147 insertions(+), 151 deletions(-) diff --git a/pug/src/dfu.pug b/pug/src/dfu.pug index c4c1445..4b539fa 100644 --- a/pug/src/dfu.pug +++ b/pug/src/dfu.pug @@ -156,7 +156,7 @@ block script // 有可能是因為不支援 cmdGetGitVersion ultra.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) } - await Swal.fire({ icon: 'info', text: 'Click OK to enter DFU mode then reconnect device!' }) + await this.alert({ icon: 'info', text: 'Please reconnect device after reboot.' }) this.showLoading({ text: 'Connect device...' }) await ultra.cmdDfuEnter() if (!ultra.isConnected()) await ultra.connect() @@ -207,7 +207,7 @@ block script // 有可能是因為不支援 cmdGetGitVersion ultra.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) } - await Swal.fire({ icon: 'info', text: 'Click OK to enter DFU mode then reconnect device!' }) + await this.alert({ icon: 'info', text: 'Please reconnect device after reboot.' }) this.showLoading({ text: 'Connect device...' }) await ultra.cmdDfuEnter() if (!ultra.isConnected()) await ultra.connect() @@ -239,8 +239,11 @@ block script async sleep (t) { await new Promise(resolve => setTimeout(resolve, t)) }, + async alert (opts) { + return await new Promise(resolve => { Swal.fire({ ...opts, didDestroy: resolve }) }) + }, async confirm (text, confirmButtonText, cancelButtonText) { - return await new Promise((resolve, reject) => { + return await new Promise(resolve => { let isConfirmed = false const args = { cancelButtonColor: '#3085d6', diff --git a/src/ChameleonUltra.ts b/src/ChameleonUltra.ts index 49a84a5..b21f7da 100644 --- a/src/ChameleonUltra.ts +++ b/src/ChameleonUltra.ts @@ -2,7 +2,7 @@ import { Buffer } from '@taichunmin/buffer' import crc16a from '@taichunmin/crc/crc16a' import crc32 from '@taichunmin/crc/crc32' import _ from 'lodash' -import { type ReadableStream, type UnderlyingSink, type WritableStreamDefaultController, WritableStream } from 'node:stream/web' +import { type ReadableStream, type WritableStream } from 'node:stream/web' import { type AnimationMode, type ButtonAction, @@ -37,8 +37,8 @@ import { isValidFreqType, Mf1KeyType, MfuCmd, - NxpMfuType, MfuVerToNxpMfuType, + NxpMfuType, RespStatus, TagType, } from './enums' @@ -129,14 +129,13 @@ function toUpperHex (buf: Buffer): string { * */ export class ChameleonUltra { - readonly #emitErr: (err: Error) => void #deviceMode: DeviceMode | null = null #isDisconnecting: boolean = false - #rxSink?: UltraRxSink | DfuRxSink + #readAsyncGenerator: ReadableToAsyncGenerator | null = null #supportedCmds: Set = new Set() + readonly #emitErr: (err: Error) => void readonly #hooks = new Map>() readonly #middlewares = new Map() - readonly #WritableStream: typeof WritableStream /** * The supported version of SDK. @@ -161,10 +160,9 @@ export class ChameleonUltra { /** * @hidden */ - port?: ChameleonSerialPort + port: ChameleonSerialPort | null = null constructor () { - this.#WritableStream = (globalThis as any)?.WritableStream ?? WritableStream this.#emitErr = (err: Error): void => { this.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) } } @@ -227,10 +225,10 @@ export class ChameleonUltra { // serial.readable pipeTo this.rxSink const promiseConnected = new Promise(resolve => this.emitter.once('connected', resolve)) - this.#rxSink = this.isDfu() ? new DfuRxSink(this) : new UltraRxSink(this) if (_.isNil(this.port.readable)) throw new Error('this.port.readable is nil') - void this.port.readable.pipeTo(new this.#WritableStream(this.#rxSink), this.#rxSink.abortController) - .catch(err => { this.#debug('rxSink', err) }) + this.#readAsyncGenerator = new ReadableToAsyncGenerator(this.port.readable) + if (this.isDfu()) void this.#startDfuReadAsyncGenerator() + else void this.#startUltraReadAsyncGenerator() const connectedAt = await promiseConnected this.#debug('core', `connected at ${connectedAt.toISOString()}`) @@ -243,6 +241,63 @@ export class ChameleonUltra { } } + async #startUltraReadAsyncGenerator (): Promise { + const generator = this.#readAsyncGenerator + if (_.isNil(generator)) throw new Error('this.#readAsyncGenerator is nil') + + try { + const bufs: Buffer[] = [] + this.emitter.emit('connected', new Date()) + for await (const chunk of generator) { + bufs.push(Buffer.isBuffer(chunk) ? chunk : Buffer.fromView(chunk)) + let concated = Buffer.concat(bufs.splice(0, bufs.length)) + try { + while (concated.length > 0) { + const sofIdx = concated.indexOf(START_OF_FRAME) + if (sofIdx < 0) break // end, SOF not found + else if (sofIdx > 0) concated = concated.subarray(sofIdx) // ignore bytes before SOF + // sof + sof lrc + cmd (2) + status (2) + data len (2) + head lrc + data + data lrc + if (concated.length < 10) break // end, buf.length < 10 + if (bufLrc(concated.subarray(2, 8)) !== concated[8]) { + concated = concated.subarray(1) // skip 1 byte, head lrc mismatch + continue + } + const lenFrame = concated.readUInt16BE(6) + 10 + if (concated.length < lenFrame) break // end, wait for more data + if (bufLrc(concated.subarray(9, lenFrame - 1)) !== concated[lenFrame - 1]) { + concated = concated.subarray(1) // skip 1 byte, data lrc mismatch + continue + } + this.emitter.emit('resp', new UltraFrame(concated.slice(0, lenFrame))) + concated = concated.subarray(lenFrame) + } + } finally { + if (concated.length > 0) bufs.push(concated) + } + } + this.emitter.emit('disconnected', new Date()) + } catch (err) { + this.#emitErr(err) + this.emitter.emit('disconnected', new Date(), err.message) + } + } + + async #startDfuReadAsyncGenerator (): Promise { + const generator = this.#readAsyncGenerator + if (_.isNil(generator)) throw new Error('this.#readAsyncGenerator is nil') + + try { + this.emitter.emit('connected', new Date()) + for await (const chunk of generator) { + this.emitter.emit('resp', new DfuFrame(Buffer.isBuffer(chunk) ? chunk : Buffer.fromView(chunk))) + } + this.emitter.emit('disconnected', new Date()) + } catch (err) { + this.#emitErr(err) + this.emitter.emit('disconnected', new Date(), err.message) + } + } + /** * Disconnect ChameleonUltra. * @group Connection Related @@ -262,12 +317,10 @@ export class ChameleonUltra { const promiseDisconnected: Promise<[Date, string | undefined]> = this.isConnected() ? new Promise(resolve => { this.emitter.once('disconnected', (disconnected: Date, reason?: string) => { resolve([disconnected, reason]) }) }) : Promise.resolve([new Date(), err.message]) - const isLocked = (): boolean => this.port?.readable?.locked ?? false - if (isLocked()) this.#rxSink?.abortController.abort(err) - while (isLocked()) await sleep(10) - await this.port?.readable?.cancel(err).catch(this.#emitErr) + await this.#readAsyncGenerator?.reader?.cancel(err).catch(this.#emitErr) await this.port?.writable?.close().catch(this.#emitErr) - delete this.port + this.#debug('core', `locked: readable = ${this.port?.readable?.locked ?? '?'}, writable = ${this.port?.writable?.locked ?? '?'}`) + this.port = null const [disconnectedAt, reason] = await promiseDisconnected this.#debug('core', `disconnected at ${disconnectedAt.toISOString()}, reason = ${reason ?? '?'}`) @@ -347,7 +400,7 @@ export class ChameleonUltra { }): Promise<() => Promise> { try { if (!this.isConnected()) await this.connect() - if (_.isNil(this.#rxSink)) throw new Error('rxSink is undefined') + if (_.isNil(this.#readAsyncGenerator)) throw new Error('#readAsyncGenerator is undefined') if (_.isNil(args.timeout)) args.timeout = this.readDefaultTimeout const respGenerator = new EventAsyncGenerator() this.emitter.on('resp', respGenerator.onData) @@ -658,11 +711,16 @@ export class ChameleonUltra { async cmdDfuEnter (): Promise { const cmd = Cmd.ENTER_BOOTLOADER // cmd = 1010 await this.#sendCmd({ cmd }) + // wait 5s for device disconnected for (let i = 500; i >= 0; i--) { if (!this.isConnected()) break - if (i === 0) throw new Error('Failed to enter bootloader mode') await sleep(10) } + // if device is still connected, disconnect it + if (this.isConnected()) { + await this.disconnect(new Error('Enter bootloader mode')) + await sleep(500) + } this.#debug('core', 'cmdDfuEnter: device disconnected') } @@ -4015,11 +4073,16 @@ export class ChameleonUltra { async dfuUpdateImage (image: DfuImage): Promise { await this.dfuUpdateObject(DfuObjType.COMMAND, image.header) await this.dfuUpdateObject(DfuObjType.DATA, image.body) + // wait 5s for device disconnected for (let i = 500; i >= 0; i--) { if (!this.isConnected()) break - if (i === 0) throw new Error('Failed to reboot device') await sleep(10) } + // if device is still connected, disconnect it + if (this.isConnected()) { + await this.disconnect(new Error('Reboot after DFU')) + await sleep(500) + } this.#debug('core', 'rebooted') } } @@ -4078,109 +4141,6 @@ const DfuErrMsg = new Map([ [DfuResCode.INSUFFICIENT_SPACE, 'The available space on the device is insufficient to hold the firmware'], ]) -class UltraRxSink implements UnderlyingSink { - #closed: boolean = false - #started: boolean = false - abortController: AbortController = new AbortController() - bufs: Buffer[] = [] - readonly #ultra: ChameleonUltra - - constructor (ultra: ChameleonUltra) { - this.#ultra = ultra - } - - start (controller: WritableStreamDefaultController): void { - if (this.#closed) throw new Error('UltraRxSink is closed') - if (this.#started) throw new Error('UltraRxSink is already started') - this.#ultra.emitter.emit('connected', new Date()) - this.#started = true - } - - write (chunk: Buffer, controller: WritableStreamDefaultController): void { - if (!this.#started || this.#closed) return - if (!Buffer.isBuffer(chunk)) chunk = Buffer.fromView(chunk) - this.bufs.push(chunk) - let buf = Buffer.concat(this.bufs.splice(0, this.bufs.length)) - try { - while (buf.length > 0) { - const sofIdx = buf.indexOf(START_OF_FRAME) - if (sofIdx < 0) break // end, SOF not found - else if (sofIdx > 0) buf = buf.subarray(sofIdx) // ignore bytes before SOF - // sof + sof lrc + cmd (2) + status (2) + data len (2) + head lrc + data + data lrc - if (buf.length < 10) break // end, buf.length < 10 - if (bufLrc(buf.subarray(2, 8)) !== buf[8]) { - buf = buf.subarray(1) // skip 1 byte, head lrc mismatch - continue - } - const lenFrame = buf.readUInt16BE(6) + 10 - if (buf.length < lenFrame) break // end, wait for more data - if (bufLrc(buf.subarray(9, lenFrame - 1)) !== buf[lenFrame - 1]) { - buf = buf.subarray(1) // skip 1 byte, data lrc mismatch - continue - } - this.#ultra.emitter.emit('resp', new UltraFrame(buf.slice(0, lenFrame))) - buf = buf.subarray(lenFrame) - } - } finally { - if (buf.length > 0) this.bufs.push(buf) - } - } - - close (): void { - if (this.#closed) return - this.#closed = true - this.abortController.abort() - this.#ultra.emitter.emit('disconnected', new Date()) - } - - abort (reason: any): void { - if (this.#closed) return - this.#closed = true - this.abortController.abort() - this.#ultra.emitter.emit('disconnected', new Date(), reason) - } -} - -class DfuRxSink implements UnderlyingSink { - #closed: boolean = false - #started: boolean = false - abortController: AbortController = new AbortController() - bufs: Buffer[] = [] - readonly #ultra: ChameleonUltra - - constructor (dfu: ChameleonUltra) { - this.#ultra = dfu - } - - start (controller: WritableStreamDefaultController): void { - if (this.#closed) throw new Error('DfuRxSink is closed') - if (this.#started) throw new Error('DfuRxSink is already started') - this.#ultra.emitter.emit('connected', new Date()) - this.#started = true - } - - write (chunk: Buffer, controller: WritableStreamDefaultController): void { - if (!this.#started || this.#closed) return - if (!Buffer.isBuffer(chunk)) chunk = Buffer.fromView(chunk) - const resp = new DfuFrame(chunk) - this.#ultra.emitter.emit('resp', resp) - } - - close (): void { - if (this.#closed) return - this.#closed = true - this.abortController.abort() - this.#ultra.emitter.emit('disconnected', new Date()) - } - - abort (reason: any): void { - if (this.#closed) return - this.#closed = true - this.abortController.abort() - this.#ultra.emitter.emit('disconnected', new Date(), reason) - } -} - export interface ChameleonSerialPort { dfuWriteObject?: (buf: Buffer, mtu?: number) => Promise isDfu?: () => boolean @@ -4304,4 +4264,28 @@ function mfuCheckRespNakCrc16a (resp: Buffer): Buffer { return data } +class ReadableToAsyncGenerator implements AsyncGenerator { + readonly reader: ReadableStreamDefaultReader + + constructor (readable: ReadableStream) { + this.reader = readable.getReader() + } + + async next (): Promise> { + return await this.reader.read() as IteratorResult + } + + async return (): Promise> { + await this.reader.cancel() + return { done: true, value: undefined } + } + + async throw (err: any): Promise> { + await this.reader.cancel(err) + return { done: true, value: undefined } + } + + [Symbol.asyncIterator] (): AsyncGenerator { return this } +} + export { Decoder as ResponseDecoder } diff --git a/src/plugin/SerialPortAdapter.ts b/src/plugin/SerialPortAdapter.ts index d6cb45d..33e7827 100644 --- a/src/plugin/SerialPortAdapter.ts +++ b/src/plugin/SerialPortAdapter.ts @@ -11,10 +11,15 @@ async function findDevicePath (): Promise { } export default class SerialPortAdapter implements ChameleonPlugin { - duplex?: SerialPort + duplex: SerialPort | null = null name = 'adapter' + readonly #emitErr: (err: Error) => void ultra?: ChameleonUltra + constructor () { + this.#emitErr = (err: Error): void => { this.ultra?.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) } + } + #debug (formatter: any, ...args: [] | any[]): void { this.ultra?.emitter.emit('debug', 'serial', formatter, ...args) } @@ -58,9 +63,9 @@ export default class SerialPortAdapter implements ChameleonPlugin { ultra.addHook('disconnect', async (ctx: any, next: () => Promise) => { if (ultra.$adapter !== adapter || _.isNil(this.duplex)) return await next() // 代表已經被其他 adapter 接管 - await next() + await next().catch(this.#emitErr) await new Promise((resolve, reject) => { this.duplex?.close(err => { _.isNil(err) ? resolve() : reject(err) }) }) - delete this.duplex + this.duplex = null }) return adapter diff --git a/src/plugin/WebbleAdapter.ts b/src/plugin/WebbleAdapter.ts index 3457dd2..7c6dc5e 100644 --- a/src/plugin/WebbleAdapter.ts +++ b/src/plugin/WebbleAdapter.ts @@ -8,28 +8,29 @@ import { setObject } from '../iifeExportHelper' const DFU_CTRL_CHAR_UUID = toCanonicalUUID('8ec90001-f315-4f60-9fb8-838830daea50') const DFU_PACKT_CHAR_UUID = toCanonicalUUID('8ec90002-f315-4f60-9fb8-838830daea50') -const DFU_SERV_UUID = toCanonicalUUID('0000fe59-0000-1000-8000-00805f9b34fb') +const DFU_SERV_UUID = toCanonicalUUID(0xFE59) const ULTRA_RX_CHAR_UUID = toCanonicalUUID('6e400002-b5a3-f393-e0a9-e50e24dcca9e') const ULTRA_SERV_UUID = toCanonicalUUID('6e400001-b5a3-f393-e0a9-e50e24dcca9e') const ULTRA_TX_CHAR_UUID = toCanonicalUUID('6e400003-b5a3-f393-e0a9-e50e24dcca9e') const BLE_SCAN_FILTERS: BluetoothLEScanFilter[] = [ { name: 'ChameleonUltra' }, // Chameleon Ultra - { services: [ULTRA_SERV_UUID] }, // Chameleon Ultra, bluefy not support name filter + { namePrefix: 'CU-' }, // Chameleon Ultra DFU { services: [DFU_SERV_UUID] }, // Chameleon Ultra DFU + { services: [ULTRA_SERV_UUID] }, // Chameleon Ultra, bluefy not support name filter ] export default class WebbleAdapter implements ChameleonPlugin { #isOpen: boolean = false bluetooth?: typeof bluetooth Buffer?: typeof Buffer - ctrlChar?: BluetoothRemoteGATTCharacteristic - device?: BluetoothDevice + ctrlChar: BluetoothRemoteGATTCharacteristic | null = null + device: BluetoothDevice | null = null emitErr: (err: Error) => void name = 'adapter' - packtChar?: BluetoothRemoteGATTCharacteristic - port?: ChameleonSerialPort - rxChar?: BluetoothRemoteGATTCharacteristic + packtChar: BluetoothRemoteGATTCharacteristic | null = null + port: ChameleonSerialPort | null = null + rxChar: BluetoothRemoteGATTCharacteristic | null = null TransformStream: typeof TransformStream ultra?: ChameleonUltra WritableStream: typeof WritableStream @@ -67,7 +68,7 @@ export default class WebbleAdapter implements ChameleonPlugin { this.device = await this.bluetooth?.requestDevice({ filters: BLE_SCAN_FILTERS, optionalServices: [DFU_SERV_UUID, ULTRA_SERV_UUID], - }).catch(err => { throw _.set(new Error(err.message), 'originalError', err) }) + }).catch(err => { throw _.set(new Error(err.message), 'originalError', err) }) ?? null if (_.isNil(this.device)) throw new Error('no device') this.device.addEventListener('gattserverdisconnected', () => { void ultra.disconnect(new Error('WebBLE gattserverdisconnected')) }) this.#debug(`device selected, name = ${this.device.name ?? 'null'}, id = ${this.device.id}`) @@ -102,7 +103,7 @@ export default class WebbleAdapter implements ChameleonPlugin { const chars = new Map(_.map((await serv.getCharacteristics()) ?? [], char => [toCanonicalUUID(char.uuid), char])) this.#debug(`gattCharUuids = ${JSON.stringify([...chars.keys()])}`) - this.rxChar = chars.get(ULTRA_RX_CHAR_UUID) + this.rxChar = chars.get(ULTRA_RX_CHAR_UUID) ?? null if (_.isNil(this.rxChar)) throw new Error(`Failed to find rxChar, uuid = ${ULTRA_TX_CHAR_UUID}`) const txChar = chars.get(ULTRA_TX_CHAR_UUID) if (_.isNil(txChar)) throw new Error(`Failed to find txChar, uuid = ${ULTRA_RX_CHAR_UUID}`) @@ -131,9 +132,9 @@ export default class WebbleAdapter implements ChameleonPlugin { const chars = new Map(_.map((await serv.getCharacteristics()) ?? [], char => [toCanonicalUUID(char.uuid), char])) this.#debug(`gattCharUuids = ${JSON.stringify([...chars.keys()])}`) - this.packtChar = chars.get(DFU_PACKT_CHAR_UUID) + this.packtChar = chars.get(DFU_PACKT_CHAR_UUID) ?? null if (_.isNil(this.packtChar)) throw new Error(`Failed to find packtChar, uuid = ${DFU_PACKT_CHAR_UUID}`) - const ctrlChar = this.ctrlChar = chars.get(DFU_CTRL_CHAR_UUID) + const ctrlChar = this.ctrlChar = chars.get(DFU_CTRL_CHAR_UUID) ?? null if (_.isNil(ctrlChar)) throw new Error(`Failed to find ctrlChar, uuid = ${DFU_CTRL_CHAR_UUID}`) ctrlChar.addEventListener('characteristicvaluechanged', txStreamOnNotify) await ctrlChar.startNotifications() @@ -150,16 +151,17 @@ export default class WebbleAdapter implements ChameleonPlugin { }) ultra.addHook('disconnect', async (ctx: any, next: () => Promise) => { - if (ultra.$adapter !== adapter || _.isNil(this.device)) return await next() // 代表已經被其他 adapter 接管 - - await next() - this.#isOpen = false - delete this.port - delete this.rxChar - delete this.ctrlChar - delete this.packtChar - if (gattIsConnected()) this.device.gatt?.disconnect() - delete this.device + try { + if (ultra.$adapter !== adapter || _.isNil(this.device)) return await next() // 代表已經被其他 adapter 接管 + + await next().catch(this.emitErr) + this.#isOpen = false + if (gattIsConnected()) this.device.gatt?.disconnect() + for (const k of ['port', 'rxChar', 'ctrlChar', 'packtChar', 'device'] as const) this[k] = null + } catch (err) { + this.emitErr(err) + throw err + } }) return adapter @@ -262,5 +264,7 @@ interface BluetoothLEScanFilter { } function toCanonicalUUID (uuid: any): string { - return _.toLower(_.isInteger(uuid) ? BluetoothUUID.canonicalUUID(uuid) : uuid) + if (_.isString(uuid) && /^[0-9a-fA-F]{1,8}$/.test(uuid)) uuid = _.parseInt(uuid, 16) + if (_.isSafeInteger(uuid)) uuid = BluetoothUUID.canonicalUUID(uuid) + return _.toLower(uuid) } diff --git a/src/plugin/WebserialAdapter.ts b/src/plugin/WebserialAdapter.ts index 5c46578..c3ed991 100644 --- a/src/plugin/WebserialAdapter.ts +++ b/src/plugin/WebserialAdapter.ts @@ -22,7 +22,7 @@ export default class WebserialAdapter implements ChameleonPlugin { #isDfu: boolean = false #isOpen: boolean = false name = 'adapter' - port?: SerialPort1 + port: SerialPort1 | null = null readonly #emitErr: (err: Error) => void readonly #serial: typeof serial readonly #TransformStream: typeof TransformStream @@ -118,7 +118,7 @@ export default class WebserialAdapter implements ChameleonPlugin { await this.port.close().catch(this.#emitErr) this.#isOpen = false this.#isDfu = false - delete this.port + this.port = null }) return adapter