• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

taichunmin / chameleon-ultra.js / 15847482271

24 Jun 2025 10:04AM UTC coverage: 69.194% (+5.8%) from 63.351%
15847482271

push

github

web-flow
v0.3.30: change to vitest and lodash-es (#196)

502 of 657 branches covered (76.41%)

Branch coverage included in aggregate %.

12 of 18 new or added lines in 14 files covered. (66.67%)

218 existing lines in 5 files now uncovered.

2683 of 3946 relevant lines covered (67.99%)

3159783.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/plugin/WebserialAdapter.ts
1
import { type Buffer } from '@taichunmin/buffer'
NEW
2
import * as _ from 'lodash-es'
×
3
import { TransformStream, WritableStream, type Transformer, type TransformStreamDefaultController } from 'stream/web'
×
4
import { serial, type SerialPort } from 'web-serial-polyfill'
×
5
import { type ChameleonPlugin, type ChameleonUltra, type PluginInstallContext } from '../ChameleonUltra'
6
import { type EventEmitter } from '../EventEmitter'
7
import { sleep } from '../helper'
×
8
import { setObject } from '../iifeExportHelper'
×
9
import { DfuOp } from '../enums'
×
10

11
// https://github.com/RfidResearchGroup/ChameleonUltra/blob/main/resource/tools/enter_dfu.py
12
const WEBSERIAL_FILTERS = [
×
13
  { usbVendorId: 0x6868, usbProductId: 0x8686 }, // Chameleon Ultra
×
14
  { usbVendorId: 0x1915, usbProductId: 0x521F }, // Chameleon Ultra DFU
×
15
]
×
16

17
function u16ToHex (num: number): string {
×
18
  return _.toUpper(`000${num.toString(16)}`.slice(-4))
×
19
}
×
20

21
/**
22
 * @see
23
 * - [Web Serial API | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API)
24
 * - [Getting started with the Web Serial API | codelabs](https://codelabs.developers.google.com/codelabs/web-serial#0)
25
 * - [Read from and write to a serial port | Chrome for Developers](https://developer.chrome.com/docs/capabilities/serial)
26
 */
27
export default class WebserialAdapter implements ChameleonPlugin {
×
28
  #isDfu: boolean = false
×
29
  #isOpen: boolean = false
×
30
  name = 'adapter'
×
31
  port: SerialPort1 | null = null
×
32
  readonly #emitErr: (err: Error) => void
×
33
  readonly #serial: typeof serial
×
34
  readonly #TransformStream: typeof TransformStream
×
35
  readonly #WritableStream: typeof WritableStream
×
36
  ultra?: ChameleonUltra
×
37

38
  constructor () {
×
39
    const navigator = (globalThis as any)?.navigator ?? {}
×
40
    this.#TransformStream = (globalThis as any)?.TransformStream ?? TransformStream
×
41
    this.#WritableStream = (globalThis as any)?.WritableStream ?? WritableStream
×
42
    this.#serial = navigator.serial ?? ('usb' in navigator ? serial : null)
×
43
    this.#emitErr = (err: Error): void => { this.ultra?.emitter.emit('error', _.set(new Error(err.message), 'originalError', err)) }
×
44
  }
×
45

46
  #debug (formatter: any, ...args: [] | any[]): void {
×
47
    this.ultra?.emitter.emit('debug', 'webserial', formatter, ...args)
×
48
  }
×
49

50
  async install (context: AdapterInstallContext, pluginOption: any): Promise<AdapterInstallResp> {
×
51
    const ultra = this.ultra = context.ultra
×
52
    const Buffer1 = context.Buffer
×
53

54
    if (!_.isNil(ultra.$adapter)) await ultra.disconnect(new Error('adapter replaced'))
×
55
    const adapter: AdapterInstallResp = {
×
56
      isSupported: () => !_.isNil(this.#serial),
×
57
    }
×
58

59
    ultra.addHook('connect', async (ctx: any, next: () => Promise<unknown>) => {
×
60
      if (ultra.$adapter !== adapter) return await next() // 代表已經被其他 adapter 接管
×
61

62
      try {
×
63
        if (!adapter.isSupported()) throw new Error('WebSerial not supported')
×
64
        this.port = await this.#serial.requestPort({ filters: WEBSERIAL_FILTERS }) as SerialPort1
×
65
        if (_.isNil(this.port)) throw new Error('user canceled')
×
66

67
        const info = await this.port.getInfo()
×
68
        this.#debug(`port selected, usbVendorId = 0x${u16ToHex(info.usbVendorId)}, usbProductId = 0x${u16ToHex(info.usbProductId)}`)
×
69
        this.#isDfu = _.isMatch(info, WEBSERIAL_FILTERS[1])
×
70

71
        // port.open
72
        await this.port.open({ baudRate: 115200 })
×
73
        while (_.isNil(this.port.readable) || _.isNil(this.port.writable)) await sleep(10) // wait for port.readable
×
74
        this.#isOpen = true
×
75
        this.port.addEventListener('disconnect', () => { void ultra.disconnect(new Error('Webserial disconnect')) })
×
76

77
        if (this.#isDfu) { // Nrf DFU
×
78
          ultra.port = {
×
79
            isOpen: () => this.#isOpen,
×
80
            isDfu: () => this.#isDfu,
×
81
            readable: this.port.readable.pipeThrough(new this.#TransformStream(new SlipDecodeTransformer(Buffer1))),
×
82
            writable: new this.#WritableStream({
×
83
              write: async (chunk: Buffer) => {
×
84
                const writer = this.port?.writable?.getWriter()
×
85
                if (_.isNil(writer)) throw new Error('Failed to getWriter(). Did you remember to use adapter plugin?')
×
86
                await writer.write(slipEncode(chunk, Buffer1))
×
87
                writer.releaseLock()
×
88
              },
×
89
            }),
×
90
            dfuWriteObject: async (buf: Buffer, mtu?: number): Promise<void> => {
×
91
              if (_.isNil(mtu)) throw new Error('mtu is required')
×
92
              const mtu1 = Math.trunc((mtu - 1) / 2) - 1 // mtu before slipEncode
×
93
              let chunk: Buffer | undefined
×
94
              const writer = this.port?.writable?.getWriter()
×
95
              if (_.isNil(writer)) throw new Error('Failed to getWriter(). Did you remember to use adapter plugin?')
×
96
              for (const buf1 of buf.chunk(mtu1)) {
×
97
                if (chunk?.length !== buf1.length) {
×
98
                  chunk = Buffer1.alloc(buf1.length + 1)
×
99
                  chunk[0] = DfuOp.OBJECT_WRITE
×
100
                }
×
101
                chunk.set(buf1, 1)
×
102
                await writer.write(slipEncode(chunk, Buffer1))
×
103
              }
×
104
              writer.releaseLock()
×
105
            },
×
106
          }
×
107
        } else { // ChameleonUltra
×
108
          ultra.port = _.merge(this.port, {
×
109
            isOpen: () => this.#isOpen,
×
110
            isDfu: () => this.#isDfu,
×
111
          }) as any
×
112
        }
×
113
        return await next()
×
114
      } catch (err) {
×
115
        this.#emitErr(err)
×
116
        throw err
×
117
      }
×
118
    })
×
119

120
    ultra.addHook('disconnect', async (ctx: any, next: () => Promise<unknown>) => {
×
121
      if (ultra.$adapter !== adapter || _.isNil(this.port)) return await next() // 代表已經被其他 adapter 接管
×
122

123
      await next().catch(this.#emitErr)
×
124
      await this.port.close().catch(this.#emitErr)
×
125
      this.#isOpen = false
×
126
      this.#isDfu = false
×
127
      this.port = null
×
128
    })
×
129

130
    return adapter
×
131
  }
×
132
}
×
133

134
setObject(globalThis, ['ChameleonUltraJS', 'WebserialAdapter'], WebserialAdapter)
×
135

136
enum SlipByte {
×
137
  END = 0xC0,
×
138
  ESC = 0xDB,
×
139
  ESC_END = 0xDC,
×
140
  ESC_ESC = 0xDD,
×
141
}
142

143
class SlipDecodeTransformer implements Transformer<Buffer, Buffer> {
×
144
  readonly #bufs: Buffer[] = []
×
145
  readonly #Buffer: typeof Buffer
×
146

147
  constructor (_Buffer: typeof Buffer) {
×
148
    this.#Buffer = _Buffer
×
149
  }
×
150

151
  transform (chunk: Buffer, controller: TransformStreamDefaultController<Buffer>): void {
×
152
    if (!this.#Buffer.isBuffer(chunk)) chunk = this.#Buffer.fromView(chunk)
×
153
    this.#bufs.push(chunk)
×
154
    let buf = this.#Buffer.concat(this.#bufs.splice(0, this.#bufs.length))
×
155
    try {
×
156
      while (buf.length > 0) {
×
157
        const endIdx = buf.indexOf(SlipByte.END)
×
158
        if (endIdx < 0) break // break, END not found
×
159
        const decoded = slipDecode(buf.subarray(0, endIdx + 1))
×
160
        if (decoded.length > 0) controller.enqueue(decoded)
×
161
        buf = buf.subarray(endIdx + 1)
×
162
      }
×
163
    } finally {
×
164
      if (buf.length > 0) this.#bufs.push(buf)
×
165
    }
×
166
  }
×
167
}
×
168

169
/**
170
 * @group Internal
171
 * @internal
172
 */
173
function slipEncode (buf: Buffer, Buffer1: typeof Buffer): Buffer {
×
174
  let len1 = buf.length
×
175
  for (const b of buf) if (b === SlipByte.END || b === SlipByte.ESC) len1++
×
176
  const encoded = Buffer1.alloc(len1 + 1)
×
177
  let i = 0
×
178
  for (const byte of buf) {
×
179
    if (byte === SlipByte.END) {
×
180
      encoded[i++] = SlipByte.ESC
×
181
      encoded[i++] = SlipByte.ESC_END
×
182
    } else if (byte === SlipByte.ESC) {
×
183
      encoded[i++] = SlipByte.ESC
×
184
      encoded[i++] = SlipByte.ESC_ESC
×
185
    } else {
×
186
      encoded[i++] = byte
×
187
    }
×
188
  }
×
189
  encoded[i] = SlipByte.END
×
190
  return encoded
×
191
}
×
192

193
/**
194
 * @group Internal
195
 * @internal
196
 */
197
function slipDecode (buf: Buffer): Buffer {
×
198
  let len1 = 0
×
199
  for (let i = 0; i < buf.length; i++) {
×
200
    if (buf[i] === SlipByte.ESC) {
×
201
      if ((++i) >= buf.length) break
×
202
      if (buf[i] === SlipByte.ESC_END) buf[len1++] = SlipByte.END
×
203
      else if (buf[i] === SlipByte.ESC_ESC) buf[len1++] = SlipByte.ESC
×
204
    } else if (buf[i] === SlipByte.END) break
×
205
    else buf[len1++] = buf[i]
×
206
  }
×
207
  return buf.slice(0, len1)
×
208
}
×
209

210
/** @inline */
211
type SerialPort1 = SerialPort & EventEmitter
212

213
/** @inline */
214
type AdapterInstallContext = PluginInstallContext & {
215
  ultra: PluginInstallContext['ultra'] & { $adapter?: any }
216
}
217

218
/** @inline */
219
interface AdapterInstallResp {
220
  isSupported: () => boolean
221
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc