From d707ef33fcf77825a419a1d31a66902835bbd651 Mon Sep 17 00:00:00 2001 From: taichunmin Date: Sat, 15 Jun 2024 00:45:55 +0800 Subject: [PATCH] add dfu support and demo page for dfu --- eslint-local-rules.cjs | 2 - package.json | 10 + pages/demos.md | 30 +- pug/include/bootstrapV4.pug | 1 + pug/src/device-settings.pug | 8 +- pug/src/dfu.pug | 193 +++++++++++ pug/src/hf14a-scanner.pug | 6 +- pug/src/mfkey32.pug | 6 +- pug/src/mifare-value.pug | 6 +- pug/src/mifare-xiaomi.pug | 6 +- pug/src/mifare1k.pug | 6 +- pug/src/test.pug | 6 +- src/ChameleonUltra.test.ts | 2 +- src/ChameleonUltra.ts | 565 +++++++++++++++++++++++++++++--- src/enums.ts | 104 ++++++ src/example/serialport.ts | 4 +- src/helper.test.ts | 13 + src/helper.ts | 49 +++ src/index.ts | 2 + src/plugin/Debug.ts | 12 +- src/plugin/DfuZip.ts | 57 ++++ src/plugin/SerialPortAdapter.ts | 1 + src/plugin/WebbleAdapter.ts | 281 ++++++++++------ src/plugin/WebserialAdapter.ts | 144 ++++++-- tsup.config.ts | 1 + typedoc.json | 2 + yarn.lock | 76 ++++- 27 files changed, 1383 insertions(+), 210 deletions(-) create mode 100644 pug/src/dfu.pug create mode 100644 src/plugin/DfuZip.ts diff --git a/eslint-local-rules.cjs b/eslint-local-rules.cjs index 56114d0..85a49c7 100644 --- a/eslint-local-rules.cjs +++ b/eslint-local-rules.cjs @@ -4,8 +4,6 @@ const _ = require('lodash') const eslintPluginTsdoc = require('eslint-plugin-tsdoc') const fs = require('fs') -fs.writeFileSync('./debug.txt', 'test\r\n', { flag: 'as' }) - function eslintPluginTsdocPatch () { const origRule = eslintPluginTsdoc.rules.syntax return { diff --git a/package.json b/package.json index 4f4b11b..9e27d21 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@taichunmin/buffer": "^0.13.6", "debug": "^4.3.4", + "jszip": "^3.10.1", "lodash": "^4.17.21", "serialport": "^12.0.0", "web-serial-polyfill": "^1.0.15", @@ -99,6 +100,15 @@ "require": "./dist/Crypto1.js", "script": "./dist/Crypto1.global.js" }, + "./plugin/DfuZip": { + "types": { + "import": "./dist/plugin/DfuZip.d.mts", + "require": "./dist/plugin/DfuZip.d.ts" + }, + "import": "./dist/plugin/DfuZip.mjs", + "require": "./dist/plugin/DfuZip.js", + "script": "./dist/plugin/DfuZip.global.js" + }, "./plugin/SerialPortAdapter": { "types": { "import": "./dist/plugin/SerialPortAdapter.d.mts", diff --git a/pages/demos.md b/pages/demos.md index 7d9f501..2ad7cb3 100644 --- a/pages/demos.md +++ b/pages/demos.md @@ -3,17 +3,20 @@ - [Demos](#demos) - [device-settings.html](#device-settingshtml) - [Features](#features) - - [mfkey32.html](#mfkey32html) + - [dfu.html](#dfuhtml) - [Features](#features-1) - [Related links](#related-links) - - [mifare1k.html](#mifare1khtml) + - [mfkey32.html](#mfkey32html) - [Features](#features-2) - - [hf14a-scanner.html](#hf14a-scannerhtml) + - [Related links](#related-links-1) + - [mifare1k.html](#mifare1khtml) - [Features](#features-3) - - [mifare-xiaomi.html](#mifare-xiaomihtml) + - [hf14a-scanner.html](#hf14a-scannerhtml) - [Features](#features-4) - - [mifare-value.html](#mifare-valuehtml) + - [mifare-xiaomi.html](#mifare-xiaomihtml) - [Features](#features-5) + - [mifare-value.html](#mifare-valuehtml) + - [Features](#features-6) ## [device-settings.html](https://taichunmin.idv.tw/chameleon-ultra.js/device-settings.html) @@ -33,6 +36,23 @@ A ChameleonUltra tool to management the device info and settings. - - - +## [dfu.html](https://taichunmin.idv.tw/chameleon-ultra.js/dfu.html) + +A tool to update firmware of ChameleonUltra. + +### Features + +- Select tag to update firmware + +### Related links + +- [Uploading the code in DFU mode](https://github.com/RfidResearchGroup/ChameleonUltra/blob/main/docs/development.md#uploading-the-code-in-dfu-mode) +- [nRF5 SDK: DFU protocol](https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/lib_dfu_transport.html) +- [GitHub: GameTec-live/ChameleonUltraGUI](https://github.com/GameTec-live/ChameleonUltraGUI/blob/main/chameleonultragui/lib/bridge/dfu.dart) +- [GitHub: thegecko/web-bluetooth-dfu](https://github.com/thegecko/web-bluetooth-dfu) + +- - - + ## [mfkey32.html](https://taichunmin.idv.tw/chameleon-ultra.js/mfkey32.html) A ChameleonUltra tool to detect the mifare key that reader is authenticating (a.k.a. MFKey32). diff --git a/pug/include/bootstrapV4.pug b/pug/include/bootstrapV4.pug index 010eab1..a99d24b 100644 --- a/pug/include/bootstrapV4.pug +++ b/pug/include/bootstrapV4.pug @@ -58,6 +58,7 @@ html(lang="zh-Hant") script(crossorigin="anonymous", src=`${baseurl}index.global.js`) script(crossorigin="anonymous", src=`${baseurl}Crypto1.global.js`) script(crossorigin="anonymous", src=`${baseurl}plugin/Debug.global.js`) + script(crossorigin="anonymous", src=`${baseurl}plugin/DfuZip.global.js`) script(crossorigin="anonymous", src=`${baseurl}plugin/WebbleAdapter.global.js`) script(crossorigin="anonymous", src=`${baseurl}plugin/WebserialAdapter.global.js`) block script diff --git a/pug/src/device-settings.pug b/pug/src/device-settings.pug index 9b26713..cff46e7 100644 --- a/pug/src/device-settings.pug +++ b/pug/src/device-settings.pug @@ -106,12 +106,12 @@ block content block script script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/joi@17/dist/joi-browser.min.js") script. - const { AnimationMode, ButtonAction, ButtonType, ChameleonDebug, ChameleonUltra, DeviceMode, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { AnimationMode, ButtonAction, ButtonType, ChameleonUltra, Debug, DeviceMode, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) const { joi: Joi } = window @@ -231,7 +231,7 @@ block script }, async btnEnterBootloader () { if (!await this.confirm('Enter bootloader?', 'Yes', 'Cancel')) return - await this.ultra.cmdEnterBootloader().catch(err => { this.ultra.emitter.emit('error', err) }) + await this.ultra.cmdDfuEnter().catch(err => { this.ultra.emitter.emit('error', err) }) }, async btnResetSettings () { if (!await this.confirm('Reset to default settings?', 'Yes', 'Cancel')) return diff --git a/pug/src/dfu.pug b/pug/src/dfu.pug new file mode 100644 index 0000000..2e6ac52 --- /dev/null +++ b/pug/src/dfu.pug @@ -0,0 +1,193 @@ +extends /include/bootstrapV4 + +block beforehtml + - const title = 'Update Firmware' + +block style + meta(property="og:description", content="A tool to update firmware of ChameleonUltra.") + meta(property="og:locale", content="zh_TW") + meta(property="og:title", content=title) + meta(property="og:type", content="website") + meta(property="og:url", content=`${baseurl}dfu.html`) + style + :sass + [v-cloak] + display: none + body, .h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 + font-family: 'Noto Sans TC', sans-serif + .input-group-prepend > .input-group-text + width: 80px + .letter-spacing-n1px + &, .btn, textarea, select, input + letter-spacing: -1px + .text-sm + font-size: 0.875rem + +block content + #app.my-3.container.text-monospace(v-cloak) + h4.mb-3.text-center.letter-spacing-n1px #[.bgicon.bgicon-chameleon-ultra.mr-1] #{title} + .form-group.letter-spacing-n1px + label Connect method: + .input-group.input-group-sm.mb-3 + select.form-control(v-model="ls.adapter") + option(value="ble") BLE (PC & Android) + option(value="usb") USB Serial (PC only) + .input-group-append: button.btn.btn-outline-secondary(@click="btnAdapterTips") #[i.fa.fa-fw.fa-question] + .form-group.letter-spacing-n1px.mb-3 + label Tag Name: + select.form-control.form-control-sm(v-model="ss.tagName") + option(v-for="[k, v] of tagNames", :value="k") {{ v }} + button.btn.btn-block.btn-outline-primary.letter-spacing-n1px.mb-2(@click="btnUploadFirmware") #[i.fa.mr-1.fa-download] Upload Firmware + +block script + script. + const { Buffer, ChameleonUltra, Debug, DeviceModel, DfuZip, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const ultraUsb = new ChameleonUltra() + ultraUsb.use(new Debug()) + ultraUsb.use(new WebserialAdapter()) + const ultraBle = new ChameleonUltra() + ultraBle.use(new Debug()) + ultraBle.use(new WebbleAdapter()) + + window.vm = new Vue({ + el: '#app', + data: { + ls: { + adapter: 'ble', + }, + ss: { + tagName: '', + }, + manifest: {}, + }, + async mounted () { + // 自動儲存功能 + for (const [storage, key] of [[localStorage, 'ls'], [sessionStorage, 'ss']]) { + try { + const saved = JSON5.parse(storage.getItem(location.pathname)) + if (saved) this.$set(this, key, _.merge(this[key], saved)) + } catch (err) {} + this.$watch(key, () => { + storage.setItem(location.pathname, JSON5.stringify(this[key])) + }, { deep: true }) + } + await this.fetchManifest() + }, + computed: { + ultra () { + return this.ls.adapter === 'usb' ? ultraUsb : ultraBle + }, + tagNames () { + return _.map(_.orderBy(this?.manifest?.releases, ['createdAt'], ['desc']), release => { + if (_.isNil(release.gitVersion)) return [release.tagName, release.tagName] + return [release.tagName, `${release.tagName} (${release.gitVersion})`] + }) + }, + releases () { + const isAsset = model => asset => asset.name.indexOf(model) >= 0 && asset.name.indexOf('app') >= 0 + const isUltraAsset = isAsset('ultra') + const isLiteAsset = isAsset('lite') + return _.fromPairs(_.map(this?.manifest?.releases, release => [release.tagName, { + ..._.pick(release, ['commit', 'gitVersion', 'prerelease', 'tagName']), + createdAt: new Date(release.createdAt), + lite: _.find(release.assets, isLiteAsset), + ultra: _.find(release.assets, isUltraAsset), + }])) + }, + }, + methods: { + async btnAdapterTips () { + await Swal.fire({ + title: 'Browser & OS', + html: 'BLE is available in ChromeOS, Chrome for Android 6.0, Mac (Chrome 56) and Windows 10 (Chrome 70), Bluefy for iPhone and iPad.
USB is available on all desktop platforms (ChromeOS, Linux, macOS, and Windows) in Chrome 89.', + }) + }, + async fetchManifest () { + this.showLoading({ text: 'Loading firmwares...' }) + const url = `https://taichunmin.idv.tw/ChameleonUltra-releases/manifest.json?t=${Math.trunc(Date.now() / 6e5)}` + this.$set(this, 'manifest', (await axios.get(url))?.data ?? {}) + this.ss.tagName = _.first(this.tagNames)[0] + Swal.close() + }, + async btnUploadFirmware () { + const { ultra } = this + const showProgress = ({ func, offset, size, type }) => { + if (func !== 'dfuUploadObject') return + const width = _.round(size > 0 ? offset / size * 100 : 0, 1) + const title = type === 2 ? 'Uploading body' : 'Uploading header' + const html = `
${title}:${offset} / ${size}
` + this.showLoading({ html }) + } + try { + this.showLoading({ text: 'Download firmware...' }) + const release = this.releases[this.ss.tagName] + if (_.isNil(release)) throw new Error('Invalid tagName') + const images = await Promise.all(_.map(['ultra', 'lite'], async model => { + const dfuZipUrl = release[model].url + ultra.emitter.emit('debug', 'web', `model = ${model}, url = ${dfuZipUrl}`) + const dfuZip = new DfuZip(new Buffer((await axios.get(dfuZipUrl, { responseType: 'arraybuffer' }))?.data)) + return await dfuZip.getAppImage() + })) + this.showLoading({ text: 'Connect device...' }) + await ultra.connect() + if (!ultra.isDfu()) { + const gitVersion = await ultra.cmdGetGitVersion() + if (!_.isNil(release.gitVersion) && release.gitVersion === gitVersion) { + const msg1 = `gitVersion(${gitVersion}) is the same, do you want to upload again?` + if (!await this.confirm(msg1, 'Yes', 'Cancel')) return + } + await ultra.cmdDfuEnter() + } + this.showLoading({ text: 'Uploading Firmware...' }) + ultra.emitter.on('progress', showProgress) + let isUploadSuccess = false + for (const image of images) { + try { + await ultra.dfuUploadImage(image) + isUploadSuccess = true + break + } catch (err) { + ultra.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) + } + } + if (!isUploadSuccess) throw new Error('Upload failed') + await Swal.fire({ icon: 'success', title: 'Upload Success' }) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Upload Failed', text: err.message }) + } + ultra.emitter.removeListener('progress', showProgress) + }, + async sleep (t) { + await new Promise(resolve => setTimeout(resolve, t)) + }, + async confirm (text, confirmButtonText, cancelButtonText) { + return await new Promise((resolve, reject) => { + let isConfirmed = false + const args = { + cancelButtonColor: '#3085d6', + cancelButtonText, + confirmButtonColor: '#d33', + confirmButtonText, + didDestroy: () => { resolve(isConfirmed) }, + focusCancel: true, + icon: 'warning', + reverseButtons: true, + showCancelButton: true, + text, + } + Swal.fire(args).then(res => { isConfirmed = res.isConfirmed }) + }) + }, + showLoading (opts = {}) { + opts = { + allowOutsideClick: false, + showConfirmButton: false, + ...opts, + } + if (Swal.isVisible()) return Swal.update(_.omit(opts, ['progressStepsDistance'])) + Swal.fire({ ...opts, didRender: () => { Swal.showLoading() } }) + }, + }, + }) + diff --git a/pug/src/hf14a-scanner.pug b/pug/src/hf14a-scanner.pug index 2a83dce..1ec5961 100644 --- a/pug/src/hf14a-scanner.pug +++ b/pug/src/hf14a-scanner.pug @@ -59,12 +59,12 @@ block content block script script. - const { ChameleonDebug, ChameleonUltra, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { ChameleonUltra, Debug, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) const toHex = buf => _.toUpper(buf.toString('hex')) diff --git a/pug/src/mfkey32.pug b/pug/src/mfkey32.pug index fcf4d2f..830ea3e 100644 --- a/pug/src/mfkey32.pug +++ b/pug/src/mfkey32.pug @@ -108,12 +108,12 @@ block content block script script. - const { Buffer, ChameleonDebug, ChameleonUltra, DeviceMode, FreqType, Mf1KeyType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { Buffer, ChameleonUltra, Debug, DeviceMode, FreqType, Mf1KeyType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) window.vm = new Vue({ diff --git a/pug/src/mifare-value.pug b/pug/src/mifare-value.pug index 6ba42b1..287831e 100644 --- a/pug/src/mifare-value.pug +++ b/pug/src/mifare-value.pug @@ -93,12 +93,12 @@ block content block script script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/joi@17/dist/joi-browser.min.js") script. - const { Buffer, ChameleonDebug, ChameleonUltra, Mf1KeyType, Mf1VblockOperator, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { Buffer, ChameleonUltra, Debug, Mf1KeyType, Mf1VblockOperator, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) window.vm = new Vue({ diff --git a/pug/src/mifare-xiaomi.pug b/pug/src/mifare-xiaomi.pug index 924fb96..c50bd5b 100644 --- a/pug/src/mifare-xiaomi.pug +++ b/pug/src/mifare-xiaomi.pug @@ -111,12 +111,12 @@ block content block script script. - const { Buffer, ChameleonDebug, ChameleonUltra, DeviceMode, FreqType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { Buffer, ChameleonUltra, Debug, DeviceMode, FreqType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) const toHex = buf => _.toUpper(buf.toString('hex')) diff --git a/pug/src/mifare1k.pug b/pug/src/mifare1k.pug index 99cfa32..99bb728 100644 --- a/pug/src/mifare1k.pug +++ b/pug/src/mifare1k.pug @@ -161,12 +161,12 @@ block content block script script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/joi@17/dist/joi-browser.min.js") script. - const { Buffer, ChameleonDebug, ChameleonUltra, DeviceMode, FreqType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const { Buffer, ChameleonUltra, Debug, DeviceMode, FreqType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) const toHex = buf => _.toUpper(buf.toString('hex')) diff --git a/pug/src/test.pug b/pug/src/test.pug index 20d36a4..025dc2f 100644 --- a/pug/src/test.pug +++ b/pug/src/test.pug @@ -24,12 +24,12 @@ block script script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/vconsole@3/dist/vconsole.min.js") script. window.vConsole = new window.VConsole() - const { Buffer, ChameleonDebug, ChameleonUltra, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS // eslint-disable-line no-unused-vars, @typescript-eslint/no-unused-vars + const { Buffer, ChameleonUltra, Debug, NrfDfu, NrfDfuWebserialAdapter, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS // eslint-disable-line no-unused-vars, @typescript-eslint/no-unused-vars const ultraUsb = new ChameleonUltra() - ultraUsb.use(new ChameleonDebug()) + ultraUsb.use(new Debug()) ultraUsb.use(new WebserialAdapter()) const ultraBle = new ChameleonUltra() - ultraBle.use(new ChameleonDebug()) + ultraBle.use(new Debug()) ultraBle.use(new WebbleAdapter()) window.vm = new Vue({ diff --git a/src/ChameleonUltra.test.ts b/src/ChameleonUltra.test.ts index c468c80..22850ba 100644 --- a/src/ChameleonUltra.test.ts +++ b/src/ChameleonUltra.test.ts @@ -111,7 +111,7 @@ describe('ChameleonUltra with BufferMockAdapter', () => { test('#cmdEnterBootloader()', async () => { // act - await ultra.cmdEnterBootloader() + await ultra.cmdDfuEnter() // assert expect(adapter.recv).toEqual([Buffer.from('11ef 03f2 0000 0000 0b 00', 'hex')]) diff --git a/src/ChameleonUltra.ts b/src/ChameleonUltra.ts index 95cda1b..901b235 100644 --- a/src/ChameleonUltra.ts +++ b/src/ChameleonUltra.ts @@ -1,20 +1,26 @@ import _ from 'lodash' import { Buffer } from '@taichunmin/buffer' -import { middlewareCompose, sleep, type MiddlewareComposeFn, versionCompare } from './helper' +import { crc32, middlewareCompose, sleep, type MiddlewareComposeFn, versionCompare } from './helper' import { EventAsyncGenerator } from './EventAsyncGenerator' import { EventEmitter } from './EventEmitter' +import { type DfuImage } from './plugin/DfuZip' import { type ReadableStream, type UnderlyingSink, type WritableStreamDefaultController, WritableStream } from 'node:stream/web' import * as Decoder from './ResponseDecoder' import { Cmd, DeviceMode, + DfuObjType, + DfuOp, + DfuResCode, Mf1KeyType, RespStatus, type AnimationMode, type ButtonAction, type ButtonType, type DeviceModel, + type DfuFwId, + type DfuFwType, type FreqType, type Mf1EmuWriteMode, type Mf1PrngType, @@ -37,7 +43,6 @@ import { const READ_DEFAULT_TIMEOUT = 5e3 const START_OF_FRAME = new Buffer(2).writeUInt16BE(0x11EF) const VERSION_SUPPORTED = { gte: '2.0', lt: '3.0' } as const -const WritableStream1: typeof WritableStream = (globalThis as any)?.WritableStream ?? WritableStream function isMf1BlockNo (block: any): boolean { return _.isInteger(block) && block >= 0 && block <= 0xFF @@ -114,11 +119,17 @@ function validateMf1BlockKey (block: any, keyType: any, key: any, prefix: string export class ChameleonUltra { #deviceMode: DeviceMode | null = null #isDisconnecting: boolean = false - #rxSink?: ChameleonRxSink + #rxSink?: UltraRxSink | DfuRxSink #supportedCmds: Set = new Set() readonly #hooks = new Map>() readonly #middlewares = new Map() + /** + * @internal + * @group Internal + */ + WritableStream: typeof WritableStream + /** * The supported version of SDK. * @group Device Related @@ -147,6 +158,17 @@ export class ChameleonUltra { */ port?: ChameleonSerialPort + /** + * @internal + * @group Internal + */ + catchErr: (err: Error) => void + + constructor () { + this.WritableStream = (globalThis as any)?.WritableStream ?? WritableStream + this.catchErr = (err: Error): void => { this.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) } + } + #debug (namespace: string, formatter: any, ...args: [] | any[]): void { this.emitter.emit('debug', namespace, formatter, ...args) } @@ -203,8 +225,8 @@ export class ChameleonUltra { // serial.readable pipeTo this.rxSink const promiseConnected = new Promise(resolve => this.emitter.once('connected', resolve)) - this.#rxSink = new ChameleonRxSink(this) - void this.port.readable.pipeTo(new WritableStream1(this.#rxSink), this.#rxSink.abortController) + this.#rxSink = this.isDfu() ? new DfuRxSink(this) : new UltraRxSink(this) + void this.port.readable.pipeTo(new this.WritableStream(this.#rxSink), this.#rxSink.abortController) .catch(err => { this.#debug('rxSink', err) }) const connectedAt = await promiseConnected @@ -237,10 +259,11 @@ export class ChameleonUltra { const promiseDisconnected = new Promise<[Date, string | undefined]>(resolve => { this.emitter.once('disconnected', (disconnected: Date, reason?: string) => { resolve([disconnected, reason]) }) }) - this.#rxSink?.abortController.abort(err) - while (this.port?.readable?.locked === true) await sleep(10) - await this.port?.readable?.cancel(err) - await this.port?.writable?.close() + 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.catchErr) + await this.port?.writable?.close().catch(this.catchErr) delete this.port const [disconnectedAt, reason] = await promiseDisconnected @@ -262,6 +285,22 @@ export class ChameleonUltra { return this?.port?.isOpen?.() ?? false } + /** + * Return true if ChameleonUltra is in DFU mode. + * @group DFU Related + */ + isDfu (): boolean { + return this?.port?.isDfu?.() ?? false + } + + /** + * Return true if DFU use slip encode/decode. + * @group DFU Related + */ + isSlip (): boolean { + return this?.port?.isSlip?.() ?? false + } + /** * Send a buffer to device. * @param buf - The buffer to be sent to device. @@ -271,7 +310,8 @@ export class ChameleonUltra { async #sendBuffer (buf: Buffer): Promise { if (!Buffer.isBuffer(buf)) throw new TypeError('buf should be a Buffer') if (!this.isConnected()) await this.connect() - this.#debug('send', ChameleonUltraFrame.inspect(buf)) + const frame = this.isDfu() ? new DfuFrame(buf) : new UltraFrame(buf) + if (!(frame instanceof DfuFrame) || frame.op !== DfuOp.OBJECT_WRITE) this.#debug('send', frame.inspect) const writer = (this.port?.writable as any)?.getWriter() if (_.isNil(writer)) throw new Error('Failed to getWriter(). Did you remember to use adapter plugin?') await writer.write(buf) @@ -304,16 +344,17 @@ export class ChameleonUltra { * @internal * @group Internal */ - async #createReadRespFn (args: { + async #createReadRespFn (args: { cmd?: Cmd - filter?: (resp: ChameleonUltraFrame) => boolean + op?: DfuOp + filter?: (resp: T) => boolean timeout?: number - }): Promise<() => Promise> { + }): Promise<() => Promise> { try { if (!this.isConnected()) await this.connect() if (_.isNil(this.#rxSink)) throw new Error('rxSink is undefined') if (_.isNil(args.timeout)) args.timeout = this.readDefaultTimeout - const respGenerator = new EventAsyncGenerator() + const respGenerator = new EventAsyncGenerator() this.emitter.on('resp', respGenerator.onData) this.emitter.once('disconnected', respGenerator.onClose) let timeout: NodeJS.Timeout | undefined @@ -329,15 +370,14 @@ export class ChameleonUltra { timeout = setTimeout(() => { respGenerator.onError(new Error(`read resp timeout (${args.timeout}ms)`)) }, args.timeout) - let resp: ChameleonUltraFrame | null = null - for await (const buf of respGenerator) { - const resp1 = new ChameleonUltraFrame(buf) - if (!_.isNil(args.cmd) && resp1.cmd !== args.cmd) continue + let resp: T | null = null + for await (const resp1 of respGenerator) { + if (!_.isNil(args.cmd) && (resp1 as UltraFrame).cmd !== args.cmd) continue + if (!_.isNil(args.op) && (resp1 as DfuFrame).op !== args.op) continue if (!(args.filter?.(resp1) ?? true)) continue - if (RespStatusFail.has(resp1.status)) { + if (resp1.errMsg) { this.#debug('respError', resp1.inspect) - const status = resp1.status - throw _.merge(new Error(RespStatusMsg.get(status)), { status, data: { resp } }) + throw _.merge(new Error(resp1.errMsg), { data: { resp1 } }) } this.#debug('resp', resp1.inspect) resp = resp1 @@ -367,7 +407,7 @@ export class ChameleonUltra { */ async cmdGetAppVersion (): Promise<`${number}.${number}`> { const cmd = Cmd.GET_APP_VERSION // cmd = 1000 - const readResp = await this.#createReadRespFn({ cmd }) + const readResp = await this.#createReadRespFn({ cmd }) await this.#sendCmd({ cmd }) const { status, data } = await readResp() if (status === RespStatus.HF_TAG_OK && data.readUInt16BE(0) === 0x0001) throw new Error('Unsupported protocol. Firmware update is required.') @@ -607,19 +647,25 @@ export class ChameleonUltra { /** * Enter bootloader mode. - * @group Device Related + * @group DFU Related * @example * ```js * async function run (ultra) { - * await ultra.cmdEnterBootloader() + * await ultra.cmdDfuEnter() * } * * await run(vm.ultra) // you can run in DevTools of https://taichunmin.idv.tw/chameleon-ultra.js/test.html * ``` */ - async cmdEnterBootloader (): Promise { + async cmdDfuEnter (): Promise { const cmd = Cmd.ENTER_BOOTLOADER // cmd = 1010 await this.#sendCmd({ cmd }) + 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) + } + this.#debug('core', 'cmdDfuEnter: device disconnected') } /** @@ -1105,7 +1151,6 @@ export class ChameleonUltra { /** * Get the device is ChameleonUltra or ChameleonLite. - * @returns `true` if device is ChameleonUltra, `false` if device is ChameleonLite. * @group Device Related * @example * ```js @@ -2841,6 +2886,338 @@ export class ChameleonUltra { for (let i = 0; i < 3; i++) acl.push((data[i] & 0xF0) >>> 4, data[i] & 0x0F) return _.every([[1, 2], [0, 5], [3, 4]], ([a, b]: [number, number]) => (acl[a] ^ acl[b]) === 0xF) } + + /** + * Retrieve DFU protocol version. + * + * Syntax and ID of this command is permanent. If protocol version changes other opcode may not be valid any more. + * @returns Protocol version. + * @group DFU Related + * @see {@link https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/lib_dfu_transport.html | DFU Protocol} + * @example + * ```js + * async function run (ultra) { + * await ultra.cmdDfuEnter() + * console.log(await ultra.cmdDfuGetProtocol()) + * } + * await run(vm.ultra) // you can run in DevTools of https://taichunmin.idv.tw/chameleon-ultra.js/test.html + * ``` + */ + async cmdDfuGetProtocol (): Promise { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.PROTOCOL_VERSION + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.OBJECT_CREATE + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.RECEIPT_NOTIF_SET + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.CRC_GET + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.OBJECT_EXECUTE + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.OBJECT_SELECT + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.MTU_GET + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack('= 2 ? respData.readUInt16LE(0) : 21 + return this.isSlip() ? Math.trunc((mtu - 1) / 2) : mtu // slip encode/decode + } + + /** + * Write selected object. + * @param buf - Data. + * @group DFU Related + * @see {@link https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/lib_dfu_transport.html | DFU Protocol} + */ + async cmdDfuWriteObject (buf: Buffer): Promise { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.OBJECT_WRITE + await this.#sendBuffer(Buffer.pack(` { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.PING + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.HARDWARE_VERSION + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.FIRMWARE_VERSION + const readResp = await this.#createReadRespFn({ op }) + await this.#sendBuffer(Buffer.pack(' { + if (!this.isConnected()) await this.connect() + if (!this.isDfu()) throw new Error('Please enter DFU mode first.') + const op = DfuOp.ABORT + await this.#sendBuffer(Buffer.pack(' { + const emitProgress = (offset: number): void => { + this.emitter.emit('progress', { + func: 'dfuUploadObject', + offset, + size: buf.length, + type, + }) + } + const uploaded = await this.cmdDfuSelectObject(type) + this.#debug('core', `uploaded = ${JSON.stringify(uploaded)}`) + let buf1 = buf.subarray(0, uploaded.offset) + let crc1 = { offset: buf1.length, crc32: crc32(buf1) } + let crcFailCnt = 0 + if (!_.isMatch(uploaded, crc1)) { // abort + this.#debug('core', 'aborted') + await this.cmdDfuAbort() + Object.assign(uploaded, await this.cmdDfuSelectObject(type)) + } + emitProgress(uploaded.offset) + const mtu = await this.cmdDfuGetMtu() - 1 + while (uploaded.offset < buf.length) { + buf1 = buf.subarray(uploaded.offset).subarray(0, uploaded.maxSize) + await this.cmdDfuCreateObject(type, buf1.length) + // write object + for (const buf2 of buf1.chunk(mtu)) await this.cmdDfuWriteObject(buf2) + // check crc + const crc2 = { offset: uploaded.offset + buf1.length, crc32: crc32(buf1, uploaded.crc32) } + crc1 = await this.cmdDfuGetObjectCrc() + if (!_.isMatch(crc1, crc2)) { + crcFailCnt++ + if (crcFailCnt > 10) throw new Error('crc32 check failed 10 times') + continue + } + await this.cmdDfuExecuteObject() + Object.assign(uploaded, crc1) + crcFailCnt = 0 + emitProgress(uploaded.offset) + } + } + + /** + * Upload DFU image. + * @param image - The DFU image. + * @group DFU Related + * @see {@link https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/lib_dfu_transport.html | DFU Protocol} + */ + async dfuUploadImage (image: DfuImage): Promise { + await this.dfuUploadObject(DfuObjType.COMMAND, image.header) + await this.dfuUploadObject(DfuObjType.DATA, image.body) + for (let i = 500; i >= 0; i--) { + if (!this.isConnected()) break + if (i === 0) throw new Error('Failed to reboot device') + await sleep(10) + } + this.#debug('core', 'rebooted') + } } const RespStatusMsg = new Map([ @@ -2886,13 +3263,37 @@ const RespStatusFail = new Set([ RespStatus.FLASH_READ_FAIL, ]) -export interface ChameleonSerialPort { - isOpen?: () => boolean - readable: ReadableStream - writable: WritableStream -} +const DfuErrMsg = new Map([ + // DFU operation result code. + [DfuResCode.INVALID, 'Invalid opcode'], + [DfuResCode.SUCCESS, 'Operation successful'], + [DfuResCode.OP_CODE_NOT_SUPPORTED, 'Opcode not supported'], + [DfuResCode.INVALID_PARAMETER, 'Missing or invalid parameter value'], + [DfuResCode.INSUFFICIENT_RESOURCES, 'Not enough memory for the data object'], + [DfuResCode.INVALID_OBJECT, 'Data object does not match the firmware and hardware requirements, the signature is wrong, or parsing the command failed'], + [DfuResCode.UNSUPPORTED_TYPE, 'Not a valid object type for a Create request'], + [DfuResCode.OPERATION_NOT_PERMITTED, 'The state of the DFU process does not allow this operation'], + [DfuResCode.OPERATION_FAILED, 'Operation failed'], + [DfuResCode.EXT_ERROR, 'Extended error'], + + // DFU extended error code. + [DfuResCode.NO_ERROR, 'No extended error code has been set. This error indicates an implementation problem'], + [DfuResCode.INVALID_ERROR_CODE, 'Invalid error code. This error code should never be used outside of development'], + [DfuResCode.WRONG_COMMAND_FORMAT, 'The format of the command was incorrect. This error code is not used in the current implementation, because NRF_DFU_RES_CODE_OP_CODE_NOT_SUPPORTED and NRF_DFU_RES_CODE_INVALID_PARAMETER cover all possible format errors'], + [DfuResCode.UNKNOWN_COMMAND, 'The command was successfully parsed, but it is not supported or unknown'], + [DfuResCode.INIT_COMMAND_INVALID, 'The init command is invalid. The init packet either has an invalid update type or it is missing required fields for the update type (for example, the init packet for a SoftDevice update is missing the SoftDevice size field)'], + [DfuResCode.FW_VERSION_FAILURE, 'The firmware version is too low. For an application or SoftDevice, the version must be greater than or equal to the current version. For a bootloader, it must be greater than the current version. to the current version. This requirement prevents downgrade attacks'], + [DfuResCode.HW_VERSION_FAILURE, 'The hardware version of the device does not match the required hardware version for the update'], + [DfuResCode.SD_VERSION_FAILURE, 'The array of supported SoftDevices for the update does not contain the FWID of the current SoftDevice or the first FWID is "0" on a bootloader which requires the SoftDevice to be present'], + [DfuResCode.SIGNATURE_MISSING, 'The init packet does not contain a signature. This error code is not used in the current implementation, because init packets without a signature are regarded as invalid'], + [DfuResCode.WRONG_HASH_TYPE, 'The hash type that is specified by the init packet is not supported by the DFU bootloader'], + [DfuResCode.HASH_FAILED, 'The hash of the firmware image cannot be calculated'], + [DfuResCode.WRONG_SIGNATURE_TYPE, 'The type of the signature is unknown or not supported by the DFU bootloader'], + [DfuResCode.VERIFICATION_FAILED, 'The hash of the received firmware image does not match the hash in the init packet'], + [DfuResCode.INSUFFICIENT_SPACE, 'The available space on the device is insufficient to hold the firmware'], +]) -class ChameleonRxSink implements UnderlyingSink { +class UltraRxSink implements UnderlyingSink { #closed: boolean = false #started: boolean = false abortController: AbortController = new AbortController() @@ -2904,8 +3305,8 @@ class ChameleonRxSink implements UnderlyingSink { } start (controller: WritableStreamDefaultController): void { - if (this.#closed) throw new Error('rxSink is closed') - if (this.#started) throw new Error('rxSink is already started') + 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 } @@ -2932,7 +3333,7 @@ class ChameleonRxSink implements UnderlyingSink { buf = buf.subarray(1) // skip 1 byte, data lrc mismatch continue } - this.#ultra.emitter.emit('resp', buf.slice(0, lenFrame)) + this.#ultra.emitter.emit('resp', new UltraFrame(buf.slice(0, lenFrame))) buf = buf.subarray(lenFrame) } } finally { @@ -2955,6 +3356,54 @@ class ChameleonRxSink implements UnderlyingSink { } } +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 { + isDfu?: () => boolean + isOpen?: () => boolean + isSlip?: () => boolean + readable: ReadableStream + writable: WritableStream +} + /** * @internal * @group Plugin Related @@ -2973,16 +3422,16 @@ export interface ChameleonPlugin { install: (context: T, pluginOption: any) => Promise } -class ChameleonUltraFrame { +class UltraFrame { buf: Buffer - constructor (buf: Buffer) { - this.buf = buf + constructor (buf: Buffer | Uint8Array) { + this.buf = Buffer.isBuffer(buf) ? buf : Buffer.fromView(buf) } - static inspect (buf: any): string { - if (!Buffer.isBuffer(buf)) return 'Invalid frame' + static inspect (resp: UltraFrame): string { // sof + sof lrc + cmd (2) + status (2) + data len (2) + head lrc + data + data lrc + const { buf } = resp return [ buf.slice(0, 2).toString('hex'), // sof + sof lrc buf.slice(2, 4).toString('hex'), // cmd @@ -2996,8 +3445,42 @@ class ChameleonUltraFrame { get cmd (): Cmd { return this.buf.readUInt16BE(2) } get data (): Buffer { return this.buf.subarray(9, -1) } - get inspect (): string { return ChameleonUltraFrame.inspect(this.buf) } + get inspect (): string { return UltraFrame.inspect(this) } get status (): number { return this.buf.readUInt16BE(4) } + get errMsg (): string | undefined { + const status = this.status + if (!RespStatusFail.has(status)) return + return RespStatusMsg.get(status) ?? `Unknown status code: ${status}` + } +} + +export class DfuFrame { + buf: Buffer + + constructor (buf: Buffer) { + this.buf = buf // 60000101 + } + + static inspect (frame: DfuFrame): string { + if (frame.isResp === 1) return `op = ${DfuOp[frame.op]}, resCode = ${DfuResCode[frame.result]}, data = ${frame.data.toString('hex')}` + if (frame.op === DfuOp.OBJECT_WRITE) return `op = ${DfuOp[frame.op]}, data.length = ${frame.data.length}` + return `op = ${DfuOp[frame.op]}, data = ${frame.data.toString('hex')}` + } + + get isResp (): number { return +(this.buf[0] === DfuOp.RESPONSE) } + get data (): Buffer { return this.buf.subarray(this.isResp === 1 ? 3 : 1) } + get inspect (): string { return DfuFrame.inspect(this) } + get op (): number { return this.buf[this.isResp] } + get result (): number { + if (this.isResp === 0) return DfuResCode.SUCCESS + return this.buf[2] === DfuResCode.EXT_ERROR ? this.buf.readUInt16BE(2) : this.buf[2] + } + + get errMsg (): string | undefined { + const result = this.result + if (result === DfuResCode.SUCCESS) return + return DfuErrMsg.get(result) ?? `Unknown DfuResCode: ${result}` + } } /** diff --git a/src/enums.ts b/src/enums.ts index 9247cf2..42e207e 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -261,6 +261,110 @@ export enum TagType { // 15xx: HF14A-4 series } +export enum DfuObjType { + /** Invalid object type. */ + INVALID = 0, + /** Command object. */ + COMMAND = 1, + /** Data object. */ + DATA = 2, +} + +export enum DfuOp { + /** Retrieve protocol version. */ + PROTOCOL_VERSION = 0x00, + /** Create selected object. */ + OBJECT_CREATE = 0x01, + /** Set receipt notification. */ + RECEIPT_NOTIF_SET = 0x02, + /** Request CRC of selected object. */ + CRC_GET = 0x03, + /** Execute selected object. */ + OBJECT_EXECUTE = 0x04, + /** Select object. */ + OBJECT_SELECT = 0x06, + /** Retrieve MTU size. */ + MTU_GET = 0x07, + /** Write selected object. */ + OBJECT_WRITE = 0x08, + /** Ping. */ + PING = 0x09, + /** Retrieve hardware version. */ + HARDWARE_VERSION = 0x0A, + /** Retrieve firmware version. */ + FIRMWARE_VERSION = 0x0B, + /** Abort the DFU procedure. */ + ABORT = 0x0C, + /** Response. */ + RESPONSE = 0x60, + /** Invalid opcode. */ + INVALID = 0xFF, +} + +export enum DfuResCode { + /** Invalid opcode. */ + INVALID = 0x00, + /** Operation successful. */ + SUCCESS = 0x01, + /** Opcode not supported. */ + OP_CODE_NOT_SUPPORTED = 0x02, + /** Missing or invalid parameter value. */ + INVALID_PARAMETER = 0x03, + /** Not enough memory for the data object. */ + INSUFFICIENT_RESOURCES = 0x04, + /** Data object does not match the firmware and hardware requirements, the signature is wrong, or parsing the command failed. */ + INVALID_OBJECT = 0x05, + /** Not a valid object type for a Create request. */ + UNSUPPORTED_TYPE = 0x07, + /** The state of the DFU process does not allow this operation. */ + OPERATION_NOT_PERMITTED = 0x08, + /** Operation failed. */ + OPERATION_FAILED = 0x0A, + /** Extended error. The next byte of the response contains the error code of the extended error. */ + EXT_ERROR = 0x0B, + /** No extended error code has been set. This error indicates an implementation problem. */ + NO_ERROR = 0x0B00, + /** Invalid error code. This error code should never be used outside of development. */ + INVALID_ERROR_CODE = 0x0B01, + /** The format of the command was incorrect. This error code is not used in the current implementation, because NRF_DFU_RES_CODE_OP_CODE_NOT_SUPPORTED and NRF_DFU_RES_CODE_INVALID_PARAMETER cover all possible format errors. */ + WRONG_COMMAND_FORMAT = 0x0B02, + /** The command was successfully parsed, but it is not supported or unknown. */ + UNKNOWN_COMMAND = 0x0B03, + /** The init command is invalid. The init packet either has an invalid update type or it is missing required fields for the update type (for example, the init packet for a SoftDevice update is missing the SoftDevice size field). */ + INIT_COMMAND_INVALID = 0x0B04, + /** The firmware version is too low. For an application or SoftDevice, the version must be greater than or equal to the current version. For a bootloader, it must be greater than the current version. to the current version. This requirement prevents downgrade attacks. */ + FW_VERSION_FAILURE = 0x0B05, + /** The hardware version of the device does not match the required hardware version for the update. */ + HW_VERSION_FAILURE = 0x0B06, + /** The array of supported SoftDevices for the update does not contain the FWID of the current SoftDevice or the first FWID is '0' on a bootloader which requires the SoftDevice to be present. */ + SD_VERSION_FAILURE = 0x0B07, + /** The init packet does not contain a signature. This error code is not used in the current implementation, because init packets without a signature are regarded as invalid. */ + SIGNATURE_MISSING = 0x0B08, + /** The hash type that is specified by the init packet is not supported by the DFU bootloader. */ + WRONG_HASH_TYPE = 0x0B09, + /** The hash of the firmware image cannot be calculated. */ + HASH_FAILED = 0x0B0A, + /** The type of the signature is unknown or not supported by the DFU bootloader. */ + WRONG_SIGNATURE_TYPE = 0x0B0B, + /** The hash of the received firmware image does not match the hash in the init packet. */ + VERIFICATION_FAILED = 0x0B0C, + /** The available space on the device is insufficient to hold the firmware. */ + INSUFFICIENT_SPACE = 0x0B0D, +} + +export enum DfuFwType { + SOFTDEVICE = 0x00, + APPLICATION = 0x01, + BOOTLOADER = 0x02, + UNKNOWN = 0xFF, +} + +export enum DfuFwId { + BOOTLOADER = 0x00, + APPLICATION = 0x01, + SOFTDEVICE = 0x02, +} + export const isAnimationMode = createIsEnum(AnimationMode) export const isButtonAction = createIsEnum(ButtonAction) export const isButtonType = createIsEnum(ButtonType) diff --git a/src/example/serialport.ts b/src/example/serialport.ts index e31e03c..eb26506 100644 --- a/src/example/serialport.ts +++ b/src/example/serialport.ts @@ -1,10 +1,10 @@ import { ChameleonUltra } from '../ChameleonUltra' -import ChameleonDebug from '../plugin/Debug' +import Debug from '../plugin/Debug' import SerialPortAdapter from '../plugin/SerialPortAdapter' async function main (): Promise { const ultra = new ChameleonUltra() - await ultra.use(new ChameleonDebug()) + await ultra.use(new Debug()) await ultra.use(new SerialPortAdapter()) console.log(`version: ${await ultra.cmdGetAppVersion()} (${await ultra.cmdGetGitVersion()})`) diff --git a/src/helper.test.ts b/src/helper.test.ts index 8844057..0ef9960 100644 --- a/src/helper.test.ts +++ b/src/helper.test.ts @@ -1,3 +1,5 @@ +import _ from 'lodash' +import { Buffer } from '@taichunmin/buffer' import * as sut from './helper' test('sleep', async () => { @@ -354,3 +356,14 @@ describe('versionCompare', () => { } }) }) + +describe('crc32()', () => { + test.each([ + { crc: '00000000', hex: '' }, + { crc: '83DCEFB7', hex: '31' }, + { crc: '1C291CA3', hex: '48656C6C6F20576F726C6421' }, + { crc: 'CBF43926', hex: '313233343536373839' }, + ])('crc32(Buffer.from("$hex", "hex")) = 0x$crc', ({ hex, crc }) => { + expect(sut.crc32(Buffer.from(hex, 'hex'))).toBe(~~_.parseInt(crc, 16)) + }) +}) diff --git a/src/helper.ts b/src/helper.ts index 365e5b2..1941e8f 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,4 +1,5 @@ import _ from 'lodash' +import { type Buffer } from '@taichunmin/buffer' export type MiddlewareComposeFn = (ctx: Record, next: () => Promise) => Promise @@ -56,3 +57,51 @@ export function versionCompare (str1: string, str2: string): number { } return 0 } + +/** + * CRC32 lookup table, Generated from http://www.sunshine2k.de/coding/javascript/crc/crc_js.html + * + * Refs: + * - https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts + * - https://github.com/SheetJS/js-crc32/blob/master/crc32.mjs + */ +const CRC32_LOOKUP = new Int32Array([ + 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3, + 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, + 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, + 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5, + 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, + 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, + 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F, + 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, + 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, + 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01, + 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, + 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, + 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB, + 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, + 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, + 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD, + 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, + 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, + 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7, + 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, + 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, + 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79, + 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, + 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, + 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713, + 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, + 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, + 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45, + 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, + 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, + 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF, + 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D, +]) + +export function crc32 (buf: Buffer, prev: number = 0): number { + let crc = prev ^ -1 + for (const u8 of buf) crc = (crc >>> 8) ^ CRC32_LOOKUP[(crc ^ u8) & 0xFF] + return crc ^ -1 +} diff --git a/src/index.ts b/src/index.ts index ab00087..8ad3312 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ export { DarksideStatus, DeviceMode, DeviceModel, + DfuFwId, + DfuFwType, FreqType, Mf1EmuWriteMode, Mf1KeyType, diff --git a/src/plugin/Debug.ts b/src/plugin/Debug.ts index 922c7ff..a034ea7 100644 --- a/src/plugin/Debug.ts +++ b/src/plugin/Debug.ts @@ -1,13 +1,13 @@ import _ from 'lodash' -import { type ChameleonPlugin, type PluginInstallContext } from '../ChameleonUltra' +import { type ChameleonPlugin, type PluginInstallContext as ChameleonCtx } from '../ChameleonUltra' import createDebugger, { type Debugger } from 'debug' -export default class ChameleonDebug implements ChameleonPlugin { - filter?: ChameleonDebugFilter +export default class Debug implements ChameleonPlugin { + filter?: DebugFilter debugers = new Map() name = 'debug' - async install (context: PluginInstallContext): Promise { + async install (context: ChameleonCtx): Promise { const { ultra } = context ultra.emitter.on('error', (err: Error) => { const errJson = errToJson(err) @@ -24,9 +24,9 @@ export default class ChameleonDebug implements ChameleonPlugin { } } -;((globalThis as any ?? {}).ChameleonUltraJS ?? {}).ChameleonDebug = ChameleonDebug // eslint-disable-line @typescript-eslint/prefer-optional-chain +;((globalThis as any ?? {}).ChameleonUltraJS ?? {}).Debug = Debug // eslint-disable-line @typescript-eslint/prefer-optional-chain -type ChameleonDebugFilter = (namespace: string, formatter: any, ...args: [] | any[]) => boolean +type DebugFilter = (namespace: string, formatter: any, ...args: [] | any[]) => boolean const ERROR_KEYS = [ 'address', diff --git a/src/plugin/DfuZip.ts b/src/plugin/DfuZip.ts new file mode 100644 index 0000000..f91f8e9 --- /dev/null +++ b/src/plugin/DfuZip.ts @@ -0,0 +1,57 @@ +import _ from 'lodash' +import { Buffer } from '@taichunmin/buffer' +import JSZip from 'jszip' + +export default class DfuZip { + readonly #buf: Buffer + #zip: JSZip | null = null + #manifest: DfuManifest | null = null + + constructor (buf: Buffer) { + this.#buf = buf + } + + async getManifest (): Promise { + if (_.isNil(this.#zip)) this.#zip = await JSZip.loadAsync(this.#buf) + if (_.isNil(this.#manifest)) { + const manifestJson = await this.#zip.file('manifest.json')?.async('string') + if (_.isNil(manifestJson)) throw new Error('Unable to find manifest, is this a proper DFU package?') + this.#manifest = JSON.parse(manifestJson).manifest + } + return this.#manifest as DfuManifest + } + + async getImage (types: DfuImageType[]): Promise { + const manifest = await this.getManifest() + for (const type of types) { + const image = manifest[type] + if (_.isNil(image)) continue + const [header, body] = await Promise.all(_.map([image.dat_file, image.bin_file], async file => { + const u8 = await this.#zip?.file(file)?.async('uint8array') + if (_.isNil(u8)) throw new Error(`Failed to read ${file} from DFU package`) + return Buffer.fromView(u8) + })) + return { type, header, body } + } + return null + } + + async getBaseImage (): Promise { + return await this.getImage(['softdevice', 'bootloader', 'softdevice_bootloader']) + } + + async getAppImage (): Promise { + return await this.getImage(['application']) + } +} + +;((globalThis as any ?? {}).ChameleonUltraJS ?? {}).DfuZip = DfuZip // eslint-disable-line @typescript-eslint/prefer-optional-chain + +export type DfuManifest = Record +export type DfuImageType = 'application' | 'softdevice' | 'bootloader' | 'softdevice_bootloader' + +export interface DfuImage { + type: DfuImageType + header: Buffer + body: Buffer +} diff --git a/src/plugin/SerialPortAdapter.ts b/src/plugin/SerialPortAdapter.ts index 06ca76c..106fd8b 100644 --- a/src/plugin/SerialPortAdapter.ts +++ b/src/plugin/SerialPortAdapter.ts @@ -45,6 +45,7 @@ export default class SerialPortAdapter implements ChameleonPlugin { this.#debug(`port connected, path = ${path}, baudRate = ${baudRate}`) ultra.port = _.merge(Duplex.toWeb(this.duplex), { isOpen: () => { return this.duplex?.isOpen ?? false }, + isDfu: () => false, // TODO: dfu }) return await next() } catch (err) { diff --git a/src/plugin/WebbleAdapter.ts b/src/plugin/WebbleAdapter.ts index 8012705..d46fb0f 100644 --- a/src/plugin/WebbleAdapter.ts +++ b/src/plugin/WebbleAdapter.ts @@ -1,37 +1,45 @@ import _ from 'lodash' -import { ReadableStream, type ReadableStreamDefaultController, type UnderlyingSink, type UnderlyingSource, WritableStream } from 'node:stream/web' +import { DfuOp } from '../enums' import { sleep } from '../helper' +import { TransformStream, type UnderlyingSink, WritableStream } from 'node:stream/web' import { type bluetooth } from 'webbluetooth' import { type Buffer } from '@taichunmin/buffer' -import { type ChameleonPlugin, type ChameleonUltra, type PluginInstallContext } from '../ChameleonUltra' - -const bluetooth1: typeof bluetooth = (globalThis as any)?.navigator?.bluetooth -const ReadableStream1: typeof ReadableStream = (globalThis as any)?.ReadableStream ?? ReadableStream -const WritableStream1: typeof WritableStream = (globalThis as any)?.WritableStream ?? WritableStream - -const BLESERIAL_FILTERS = [ - { name: 'ChameleonUltra' }, -] - -const BLESERIAL_UUID = [ - { // ChameleonUltra - serv: '6e400001-b5a3-f393-e0a9-e50e24dcca9e', - send: '6e400002-b5a3-f393-e0a9-e50e24dcca9e', - recv: '6e400003-b5a3-f393-e0a9-e50e24dcca9e', - }, +import { type ChameleonPlugin, type ChameleonUltra, type PluginInstallContext, type ChameleonSerialPort } from '../ChameleonUltra' + +const DFU_CTRL_CHAR_UUID = '8ec90001-f315-4f60-9fb8-838830daea50' +const DFU_PACKT_CHAR_UUID = '8ec90002-f315-4f60-9fb8-838830daea50' +const DFU_SERV_UUID = '0000fe59-0000-1000-8000-00805f9b34fb' +const ULTRA_RX_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' +const ULTRA_SERV_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e' +const ULTRA_TX_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' + +const BLE_SCAN_FILTERS: BluetoothLEScanFilter[] = [ + { name: 'ChameleonUltra' }, // Chameleon Ultra + { services: [DFU_SERV_UUID] }, // Chameleon Ultra DFU ] export default class WebbleAdapter implements ChameleonPlugin { + #isOpen: boolean = false + bluetooth?: typeof bluetooth Buffer?: typeof Buffer + catchErr: (err: Error) => void + ctrlChar?: BluetoothRemoteGATTCharacteristic device?: BluetoothDevice - isOpen: boolean = false name = 'adapter' - recv?: BluetoothRemoteGATTCharacteristic - rxSource?: ChameleonWebbleAdapterRxSource - send?: BluetoothRemoteGATTCharacteristic - serv?: BluetoothRemoteGATTService - txSink?: ChameleonWebbleAdapterTxSink + packtChar?: BluetoothRemoteGATTCharacteristic + port?: ChameleonSerialPort + TransformStream: typeof TransformStream + rxChar?: BluetoothRemoteGATTCharacteristic ultra?: ChameleonUltra + WritableStream: typeof WritableStream + + constructor () { + const navigator = (globalThis as any)?.navigator ?? {} + this.bluetooth = navigator?.bluetooth + this.WritableStream = (globalThis as any)?.WritableStream ?? WritableStream + this.TransformStream = (globalThis as any)?.TransformStream ?? TransformStream + this.catchErr = (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', 'webble', formatter, ...args) @@ -42,10 +50,10 @@ export default class WebbleAdapter implements ChameleonPlugin { ;[this.ultra, this.Buffer] = [ultra, Buffer] if (!_.isNil(ultra.$adapter)) await ultra.disconnect(new Error('adapter replaced')) - const adapter: any = {} - - const _isSupported = await bluetooth1?.getAvailability() ?? false - adapter.isSupported = (): boolean => _isSupported + const _isSupported = await this.bluetooth?.getAvailability() ?? false + const adapter: AdapterInstallResp = { + isSupported: (): boolean => _isSupported, + } // connect gatt const gattIsConnected = (): boolean => { return this.device?.gatt?.connected ?? false } @@ -54,59 +62,80 @@ export default class WebbleAdapter implements ChameleonPlugin { if (ultra.$adapter !== adapter) return await next() // 代表已經被其他 adapter 接管 try { - if (adapter.isSupported() !== true) throw new Error('WebSerial not supported') - this.device = await bluetooth1?.requestDevice({ - filters: BLESERIAL_FILTERS, - optionalServices: _.uniq(_.map(BLESERIAL_UUID, 'serv')), + if (!adapter.isSupported()) throw new Error('WebSerial not supported') + this.device = await this.bluetooth?.requestDevice({ + filters: BLE_SCAN_FILTERS, + optionalServices: [DFU_SERV_UUID, ULTRA_SERV_UUID], }) 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}`) - this.rxSource = new ChameleonWebbleAdapterRxSource(this) - this.txSink = new ChameleonWebbleAdapterTxSink(this) - for (let i = 0; i < 100; i++) { - this.#debug(`gatt connecting, retry = ${i}`) - if (!gattIsConnected()) await this.device.gatt?.connect().catch((err: any) => { this.#debug(err.message) }) - - // find serv, send, recv, ctrl - // uuid from [bluefy](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055) is uppercase - const primaryServices = _.map(await this.device.gatt?.getPrimaryServices(), serv => _.toLower(serv.uuid)) - this.#debug(`primaryServices = ${JSON.stringify(primaryServices)}`) - for (const uuids of BLESERIAL_UUID) { - try { - if (!_.includes(primaryServices, uuids.serv)) continue - this.serv = await this.device.gatt?.getPrimaryService(uuids.serv) - this.send = await this.serv?.getCharacteristic(uuids.send) - this.recv = await this.serv?.getCharacteristic(uuids.recv) - this.recv?.addEventListener('characteristicvaluechanged', (event: any): void => this.rxSource?.onNotify(event)) - await this.recv?.startNotifications() - } catch (err) { - delete this.serv - delete this.send - delete this.recv - } - - if (!_.isNil(this.send) && !_.isNil(this.recv)) { - this.#debug(`gatt connected, serv = ${this.serv?.uuid ?? '?'}, recv = ${this.recv?.uuid ?? '?'}, send = ${this.send?.uuid ?? '?'}'`) - this.isOpen = true - break - } - } - if (this.isOpen) break + if (gattIsConnected()) break + await this.device.gatt?.connect().catch(this.catchErr) await sleep(100) } - if (!this.isOpen) throw new Error('Failed to connect gatt') - this.device.addEventListener('gattserverdisconnected', () => { void ultra.disconnect(new Error('Webble gattserverdisconnected')) }) - - ultra.port = { - isOpen: () => { return this.isOpen }, - readable: new ReadableStream1(this.rxSource), - writable: new WritableStream1(this.txSink), + if (!gattIsConnected()) throw new Error('Failed to connect gatt') + + const servs = (await this.device.gatt?.getPrimaryServices()) ?? [] + const servUuids = new Set(_.map(servs, serv => _.toLower(serv.uuid))) + this.#debug(`gattServUuids = ${JSON.stringify([...servUuids])}`) + + const txStream = new this.TransformStream() + const txStreamOnNotify = async (event: any): Promise => { + const dv = event?.target?.value + if (!ArrayBuffer.isView(dv)) return + const writer = txStream.writable.getWriter() + if (_.isNil(writer)) throw new Error('Failed to get txStream writer') + await writer.write(this.Buffer?.fromView(dv)) + writer.releaseLock() + } + if (servUuids.has(ULTRA_SERV_UUID)) { + this.port = { + isOpen: () => this.#isOpen, + readable: txStream.readable, + writable: new this.WritableStream(new UltraRxSink(this)), + } + const serv = _.find(servs, serv => _.toLower(serv.uuid) === ULTRA_SERV_UUID) + if (_.isNil(serv)) throw new Error(`Failed to find gatt serv, uuid = ${ULTRA_SERV_UUID}`) + const chars = await serv.getCharacteristics() + this.#debug(`gattCharUuids = ${JSON.stringify(_.map(chars, char => char.uuid))}`) + + this.rxChar = await serv.getCharacteristic(ULTRA_RX_CHAR_UUID) + if (_.isNil(this.rxChar)) throw new Error(`Failed to find rxChar, uuid = ${ULTRA_TX_CHAR_UUID}`) + const txChar = await serv.getCharacteristic(ULTRA_TX_CHAR_UUID) + if (_.isNil(txChar)) throw new Error(`Failed to find txChar, uuid = ${ULTRA_RX_CHAR_UUID}`) + txChar.addEventListener('characteristicvaluechanged', txStreamOnNotify) + await txChar.startNotifications() + this.#isOpen = true + } else if (servUuids.has(DFU_SERV_UUID)) { + this.port = { + isOpen: () => this.#isOpen, + isDfu: () => true, + readable: txStream.readable, + writable: new this.WritableStream(new DfuRxSink(this)), + } + const serv = _.find(servs, serv => _.toLower(serv.uuid) === DFU_SERV_UUID) + if (_.isNil(serv)) throw new Error(`Failed to find gatt serv, uuid = ${DFU_SERV_UUID}`) + const chars = await serv.getCharacteristics() + this.#debug(`gattCharUuids = ${JSON.stringify(_.map(chars, char => char.uuid))}`) + + this.packtChar = await serv.getCharacteristic(DFU_PACKT_CHAR_UUID) + if (_.isNil(this.packtChar)) throw new Error(`Failed to find packtChar, uuid = ${DFU_PACKT_CHAR_UUID}`) + const ctrlChar = this.ctrlChar = await serv.getCharacteristic(DFU_CTRL_CHAR_UUID) + if (_.isNil(ctrlChar)) throw new Error(`Failed to find ctrlChar, uuid = ${DFU_CTRL_CHAR_UUID}`) + ctrlChar.addEventListener('characteristicvaluechanged', txStreamOnNotify) + await ctrlChar.startNotifications() + this.#isOpen = true } + + if (!this.#isOpen) throw new Error('Failed to find supported service') + ultra.port = this.port return await next() } catch (err) { - this.#debug(`Failed to connect: ${err.message as string}`) + this.catchErr(err) + await ultra.disconnect(err) throw err } }) @@ -115,22 +144,16 @@ export default class WebbleAdapter implements ChameleonPlugin { if (ultra.$adapter !== adapter || _.isNil(this.device)) return await next() // 代表已經被其他 adapter 接管 await next() - if (!_.isNil(this.recv)) { - if (gattIsConnected()) await this.recv.stopNotifications() - delete this.recv - delete this.rxSource - } - if (!_.isNil(this.send)) { - delete this.send - delete this.txSink - } - if (!_.isNil(this.serv)) delete this.serv + this.#isOpen = false + delete this.port + delete this.rxChar + delete this.ctrlChar + delete this.packtChar if (gattIsConnected()) this.device.gatt?.disconnect() - this.isOpen = false delete this.device }) - return adapter as AdapterInstallResp + return adapter } } @@ -141,36 +164,51 @@ type AdapterInstallContext = PluginInstallContext & { } interface AdapterInstallResp { - isSuppored: () => boolean + isSupported: () => boolean } -class ChameleonWebbleAdapterRxSource implements UnderlyingSource { - #controller?: ReadableStreamDefaultController +class UltraRxSink implements UnderlyingSink { readonly #adapter: WebbleAdapter + Buffer: typeof Buffer + + constructor (adapter: WebbleAdapter) { + this.#adapter = adapter + if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null') + this.Buffer = this.#adapter.Buffer + } #debug (formatter: any, ...args: [] | any[]): void { this.#adapter.ultra?.emitter.emit('debug', 'webble', formatter, ...args) } - constructor (adapter: WebbleAdapter) { this.#adapter = adapter } - - start (controller: ReadableStreamDefaultController): void { this.#controller = controller } - - onNotify (event: any): void { - const buf = this.#adapter.Buffer?.fromView((event?.target?.value as DataView)) - this.#debug(`onNotify = ${buf?.toString('hex')}`) - this.#controller?.enqueue(buf) + async write (chunk: Buffer): Promise { + try { + if (_.isNil(this.#adapter.rxChar)) throw new Error('this.#adapter.rxChar can not be null') + + // 20 bytes are left for the attribute data + // https://stackoverflow.com/questions/38913743/maximum-packet-length-for-bluetooth-le + let buf2: Buffer | null = null + for (const buf1 of chunk.chunk(20)) { + if (!this.Buffer.isBuffer(buf2) || buf1.length !== buf2.length) buf2 = new this.Buffer(buf1.length) + buf2.set(buf1) + this.#debug(`bleWrite = ${buf2.toString('hex')}`) + await this.#adapter.rxChar.writeValueWithoutResponse(buf2.buffer) + } + } catch (err) { + this.#adapter.catchErr(err) + throw err + } } } -class ChameleonWebbleAdapterTxSink implements UnderlyingSink { +class DfuRxSink implements UnderlyingSink { readonly #adapter: WebbleAdapter - readonly #Buffer: typeof Buffer + Buffer: typeof Buffer constructor (adapter: WebbleAdapter) { this.#adapter = adapter if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null') - this.#Buffer = this.#adapter.Buffer + this.Buffer = this.#adapter.Buffer } #debug (formatter: any, ...args: [] | any[]): void { @@ -178,17 +216,46 @@ class ChameleonWebbleAdapterTxSink implements UnderlyingSink { } async write (chunk: Buffer): Promise { - if (_.isNil(this.#adapter.send)) throw new Error('this.#adapter.send can not be null') - - // 20 bytes are left for the attribute data - // https://stackoverflow.com/questions/38913743/maximum-packet-length-for-bluetooth-le - let buf1: Buffer | null = null - for (let i = 0; i < chunk.length; i += 20) { - const buf2 = chunk.subarray(i, i + 20) - if (_.isNil(buf1) || buf1.length !== buf2.length) buf1 = new this.#Buffer(buf2.length) - buf1.set(buf2) - this.#debug(`bleWrite = ${buf1.toString('hex')}`) - await this.#adapter.send?.writeValueWithoutResponse(buf1.buffer) + try { + if (chunk.length !== chunk.buffer.byteLength) chunk = chunk.slice() + + if (chunk[0] === DfuOp.OBJECT_WRITE) { + if (_.isNil(this.#adapter.packtChar)) throw new Error('this.#adapter.packtChar can not be null') + if (chunk.length > 21) throw new Error('chunk.length > 20 (BLE MTU)') + await this.#adapter.packtChar.writeValueWithoutResponse(chunk.slice(1).buffer) + await sleep(10) // wait for data to be processed + } else { + if (_.isNil(this.#adapter.ctrlChar)) throw new Error('this.#adapter.ctrlChar can not be null') + if (chunk.length > 20) throw new Error('chunk.length > 20 (BLE MTU)') + this.#debug(`bleWrite = ${chunk.toString('hex')}`) + await this.#adapter.ctrlChar.writeValueWithResponse(chunk.buffer) + } + } catch (err) { + this.#adapter.catchErr(err) + throw err } } } + +type BluetoothServiceUUID = number | string + +interface BluetoothManufacturerDataFilter extends BluetoothDataFilter { + companyIdentifier: number +} + +interface BluetoothServiceDataFilter extends BluetoothDataFilter { + service: BluetoothServiceUUID +} + +interface BluetoothDataFilter { + readonly dataPrefix?: T | undefined + readonly mask?: T | undefined +} + +interface BluetoothLEScanFilter { + readonly name?: string | undefined + readonly namePrefix?: string | undefined + readonly services?: BluetoothServiceUUID[] | undefined + readonly manufacturerData?: Array> | undefined + readonly serviceData?: Array> | undefined +} diff --git a/src/plugin/WebserialAdapter.ts b/src/plugin/WebserialAdapter.ts index e6432f7..1154b01 100644 --- a/src/plugin/WebserialAdapter.ts +++ b/src/plugin/WebserialAdapter.ts @@ -1,15 +1,15 @@ import _ from 'lodash' import { serial, type SerialPort } from 'web-serial-polyfill' import { sleep } from '../helper' +import { type Buffer } from '@taichunmin/buffer' import { type ChameleonPlugin, type ChameleonUltra, type PluginInstallContext } from '../ChameleonUltra' import { type EventEmitter } from '../EventEmitter' +import { TransformStream, type Transformer, type TransformStreamDefaultController, WritableStream } from 'stream/web' -type SerialPort1 = SerialPort & EventEmitter -const navigator = (globalThis as any)?.navigator ?? {} -const serial1: typeof serial = navigator.serial ?? ('usb' in navigator ? serial : null) - +// https://github.com/RfidResearchGroup/ChameleonUltra/blob/main/resource/tools/enter_dfu.py const WEBSERIAL_FILTERS = [ { usbVendorId: 0x6868, usbProductId: 0x8686 }, // Chameleon Ultra + { usbVendorId: 0x1915, usbProductId: 0x521F }, // Chameleon Ultra DFU ] function u16ToHex (num: number): string { @@ -17,10 +17,21 @@ function u16ToHex (num: number): string { } export default class WebserialAdapter implements ChameleonPlugin { - isOpen: boolean = false + #isDfu: boolean = false + #isOpen: boolean = false name = 'adapter' port?: SerialPort1 + serial: typeof serial + TransformStream: typeof TransformStream ultra?: ChameleonUltra + WritableStream: typeof WritableStream + + constructor () { + const navigator = (globalThis as any)?.navigator ?? {} + this.TransformStream = (globalThis as any)?.TransformStream ?? TransformStream + this.WritableStream = (globalThis as any)?.WritableStream ?? WritableStream + this.serial = navigator.serial ?? ('usb' in navigator ? serial : null) + } #debug (formatter: any, ...args: [] | any[]): void { this.ultra?.emitter.emit('debug', 'webserial', formatter, ...args) @@ -28,31 +39,52 @@ export default class WebserialAdapter implements ChameleonPlugin { async install (context: AdapterInstallContext, pluginOption: any): Promise { const ultra = this.ultra = context.ultra + const Buffer1 = context.Buffer if (!_.isNil(ultra.$adapter)) await ultra.disconnect(new Error('adapter replaced')) - const adapter: any = {} - - adapter.isSupported = (): boolean => !_.isNil(serial1) + const adapter: AdapterInstallResp = { + isSupported: () => !_.isNil(this.serial), + } ultra.addHook('connect', async (ctx: any, next: () => Promise) => { if (ultra.$adapter !== adapter) return await next() // 代表已經被其他 adapter 接管 try { - if (adapter.isSupported() !== true) throw new Error('WebSerial not supported') - this.port = await serial1.requestPort({ filters: WEBSERIAL_FILTERS }) as SerialPort1 + if (!adapter.isSupported()) throw new Error('WebSerial not supported') + this.port = await this.serial.requestPort({ filters: WEBSERIAL_FILTERS }) as SerialPort1 if (_.isNil(this.port)) throw new Error('user canceled') + const info = await this.port.getInfo() + this.#debug(`port selected, usbVendorId = 0x${u16ToHex(info.usbVendorId)}, usbProductId = 0x${u16ToHex(info.usbProductId)}`) + this.#isDfu = _.isMatch(info, WEBSERIAL_FILTERS[1]) + // port.open await this.port.open({ baudRate: 115200 }) while (_.isNil(this.port.readable) || _.isNil(this.port.writable)) await sleep(10) // wait for port.readable - this.isOpen = true - - const info = await this.port.getInfo() as { usbVendorId: number, usbProductId: number } - this.#debug(`port selected, usbVendorId = 0x${u16ToHex(info.usbVendorId)}, usbProductId = 0x${u16ToHex(info.usbProductId)}`) + this.#isOpen = true this.port.addEventListener('disconnect', () => { void ultra.disconnect(new Error('Webserial disconnect')) }) - ultra.port = _.merge(this.port, { - isOpen: () => this.isOpen, - }) + + if (this.#isDfu) { // Nrf DFU + ultra.port = { + isOpen: () => this.#isOpen, + isDfu: () => this.#isDfu, + isSlip: () => true, + readable: this.port.readable.pipeThrough(new this.TransformStream(new SlipDecodeTransformer(Buffer1))), + writable: new this.WritableStream({ + write: async (chunk: Buffer) => { + const writer = this.port?.writable.getWriter() + if (_.isNil(writer)) throw new Error('Failed to getWriter(). Did you remember to use adapter plugin?') + await writer.write(slipEncode(chunk, Buffer1)) + writer.releaseLock() + }, + }), + } + } else { // ChameleonUltra + ultra.port = _.merge(this.port, { + isOpen: () => this.#isOpen, + isDfu: () => this.#isDfu, + }) + } return await next() } catch (err) { this.#debug(err) @@ -63,22 +95,90 @@ export default class WebserialAdapter implements ChameleonPlugin { ultra.addHook('disconnect', async (ctx: any, next: () => Promise) => { if (ultra.$adapter !== adapter || _.isNil(this.port)) return await next() // 代表已經被其他 adapter 接管 - await next() - await this.port.close() - this.isOpen = false + await next().catch(ultra.catchErr) + await this.port.close().catch(ultra.catchErr) + this.#isOpen = false + this.#isDfu = false delete this.port }) - return adapter as AdapterInstallResp + return adapter } } ;((globalThis as any ?? {}).ChameleonUltraJS ?? {}).WebserialAdapter = WebserialAdapter // eslint-disable-line @typescript-eslint/prefer-optional-chain +enum SlipByte { + END = 0xC0, + ESC = 0xDB, + ESC_END = 0xDC, + ESC_ESC = 0xDD, +} + +class SlipDecodeTransformer implements Transformer { + readonly #bufs: Buffer[] = [] + readonly #Buffer: typeof Buffer + + constructor (_Buffer: typeof Buffer) { + this.#Buffer = _Buffer + } + + transform (chunk: Buffer, controller: TransformStreamDefaultController): void { + if (!this.#Buffer.isBuffer(chunk)) chunk = this.#Buffer.fromView(chunk) + this.#bufs.push(chunk) + let buf = this.#Buffer.concat(this.#bufs.splice(0, this.#bufs.length)) + try { + while (buf.length > 0) { + const endIdx = buf.indexOf(SlipByte.END) + if (endIdx < 0) break // break, END not found + const decoded = slipDecode(buf.subarray(0, endIdx + 1)) + if (decoded.length > 0) controller.enqueue(decoded) + buf = buf.subarray(endIdx + 1) + } + } finally { + if (buf.length > 0) this.#bufs.push(buf) + } + } +} + +export function slipEncode (buf: Buffer, Buffer1: typeof Buffer): Buffer { + let len1 = buf.length + for (const b of buf) if (b === SlipByte.END || b === SlipByte.ESC) len1++ + const encoded = Buffer1.alloc(len1 + 1) + let i = 0 + for (const byte of buf) { + if (byte === SlipByte.END) { + encoded[i++] = SlipByte.ESC + encoded[i++] = SlipByte.ESC_END + } else if (byte === SlipByte.ESC) { + encoded[i++] = SlipByte.ESC + encoded[i++] = SlipByte.ESC_ESC + } else { + encoded[i++] = byte + } + } + encoded[i] = SlipByte.END + return encoded +} + +export function slipDecode (buf: Buffer): Buffer { + let len1 = 0 + for (let i = 0; i < buf.length; i++) { + if (buf[i] === SlipByte.ESC) { + if ((++i) >= buf.length) break + if (buf[i] === SlipByte.ESC_END) buf[len1++] = SlipByte.END + else if (buf[i] === SlipByte.ESC_ESC) buf[len1++] = SlipByte.ESC + } else if (buf[i] === SlipByte.END) break + else buf[len1++] = buf[i] + } + return buf.slice(0, len1) +} + +type SerialPort1 = SerialPort & EventEmitter type AdapterInstallContext = PluginInstallContext & { ultra: PluginInstallContext['ultra'] & { $adapter?: any } } interface AdapterInstallResp { - isSuppored: () => boolean + isSupported: () => boolean } diff --git a/tsup.config.ts b/tsup.config.ts index 3aa5cb5..b9a8bd6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -41,6 +41,7 @@ export default defineConfig((options): Options[] => [ 'src/Crypto1.ts', 'src/plugin/BufferMockAdapter.ts', 'src/plugin/Debug.ts', + 'src/plugin/DfuZip.ts', 'src/plugin/SerialPortAdapter.ts', 'src/plugin/WebbleAdapter.ts', 'src/plugin/WebserialAdapter.ts', diff --git a/typedoc.json b/typedoc.json index 0929ba3..f757539 100644 --- a/typedoc.json +++ b/typedoc.json @@ -15,6 +15,8 @@ "entryPoints": [ "src/index.ts", "src/Crypto1.ts", + "src/plugin/Debug.ts", + "src/plugin/DfuZip.ts", "src/plugin/SerialPortAdapter.ts", "src/plugin/WebbleAdapter.ts", "src/plugin/WebserialAdapter.ts" diff --git a/yarn.lock b/yarn.lock index 47893b6..a320f42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2153,6 +2153,11 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -3281,6 +3286,11 @@ ignore@^5.3.1: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immutable@^4.0.0: version "4.3.4" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f" @@ -3315,7 +3325,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3520,6 +3530,11 @@ isarray@^2.0.5: resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -4073,6 +4088,16 @@ jstransformer@1.0.0: is-promise "^2.0.0" promise "^7.0.1" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -4098,6 +4123,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.1.tgz#9d8a246fa753106cfc205fd2d77042faca56e5e3" @@ -4608,6 +4640,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + param-case@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" @@ -4754,6 +4791,11 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + promise@^7.0.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -4939,6 +4981,19 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@~2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -5093,6 +5148,11 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-regex-test@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" @@ -5191,6 +5251,11 @@ set-function-name@^2.0.0: functions-have-names "^1.2.3" has-property-descriptors "^1.0.0" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -5389,6 +5454,13 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -5849,7 +5921,7 @@ url-join@^4.0.1: resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== -util-deprecate@^1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==