• 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/WebbleAdapter.ts
1
import { type Buffer } from '@taichunmin/buffer'
NEW
2
import * as _ from 'lodash-es'
×
3
import { TransformStream, type UnderlyingSink, WritableStream } from 'node:stream/web'
×
4
import { type bluetooth } from 'webbluetooth'
5
import { type ChameleonPlugin, type ChameleonSerialPort, type ChameleonUltra, type PluginInstallContext } from '../ChameleonUltra'
6
import { sleep } from '../helper'
×
7
import { setObject } from '../iifeExportHelper'
×
8

9
const DFU_CTRL_CHAR_UUID = toCanonicalUUID('8ec90001-f315-4f60-9fb8-838830daea50')
×
10
const DFU_PACKT_CHAR_UUID = toCanonicalUUID('8ec90002-f315-4f60-9fb8-838830daea50')
×
11
const DFU_SERV_UUID = toCanonicalUUID(0xFE59)
×
12
const ULTRA_RX_CHAR_UUID = toCanonicalUUID('6e400002-b5a3-f393-e0a9-e50e24dcca9e')
×
13
const ULTRA_SERV_UUID = toCanonicalUUID('6e400001-b5a3-f393-e0a9-e50e24dcca9e')
×
14
const ULTRA_TX_CHAR_UUID = toCanonicalUUID('6e400003-b5a3-f393-e0a9-e50e24dcca9e')
×
15

16
const BLE_SCAN_FILTERS: BluetoothLEScanFilter[] = [
×
17
  { name: 'ChameleonUltra' }, // Chameleon Ultra
×
18
  { namePrefix: 'CU-' }, // Chameleon Ultra DFU
×
19
  { services: [DFU_SERV_UUID] }, // Chameleon Ultra DFU
×
20
  { services: [ULTRA_SERV_UUID] }, // Chameleon Ultra, bluefy not support name filter
×
21
]
×
22

23
export default class WebbleAdapter implements ChameleonPlugin {
×
24
  #isOpen: boolean = false
×
25
  bluetooth?: typeof bluetooth
×
26
  Buffer?: typeof Buffer
×
27
  ctrlChar: BluetoothRemoteGATTCharacteristic | null = null
×
28
  device: BluetoothDevice | null = null
×
29
  emitErr: (err: Error) => void
×
30
  name = 'adapter'
×
31
  packtChar: BluetoothRemoteGATTCharacteristic | null = null
×
32
  port: ChameleonSerialPort | null = null
×
33
  rxChar: BluetoothRemoteGATTCharacteristic | null = null
×
34
  TransformStream: typeof TransformStream
×
35
  ultra?: ChameleonUltra
×
36
  WritableStream: typeof WritableStream
×
37

38
  constructor () {
×
39
    const navigator = (globalThis as any)?.navigator ?? {}
×
40
    this.bluetooth = navigator?.bluetooth
×
41
    this.WritableStream = (globalThis as any)?.WritableStream ?? WritableStream
×
42
    this.TransformStream = (globalThis as any)?.TransformStream ?? TransformStream
×
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', 'webble', formatter, ...args)
×
48
  }
×
49

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

54
    if (!_.isNil(ultra.$adapter)) await ultra.disconnect(new Error('adapter replaced'))
×
55
    const _isSupported = await this.bluetooth?.getAvailability() ?? false
×
56
    const adapter: AdapterInstallResp = {
×
57
      isSupported: (): boolean => _isSupported,
×
58
    }
×
59

60
    // connect gatt
61
    const gattIsConnected = (): boolean => { return this.device?.gatt?.connected ?? false }
×
62

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

66
      try {
×
67
        if (!adapter.isSupported()) throw new Error('WebBLE not supported')
×
68
        this.device = await this.bluetooth?.requestDevice({
×
69
          filters: BLE_SCAN_FILTERS,
×
70
          optionalServices: [DFU_SERV_UUID, ULTRA_SERV_UUID],
×
71
        }).catch(err => { throw _.set(new Error(err.message), 'originalError', err) }) ?? null
×
72
        if (_.isNil(this.device)) throw new Error('no device')
×
73
        this.device.addEventListener('gattserverdisconnected', () => { void ultra.disconnect(new Error('WebBLE gattserverdisconnected')) })
×
74
        this.#debug(`device selected, name = ${this.device.name ?? 'null'}, id = ${this.device.id}`)
×
75

76
        for (let i = 0; i < 100; i++) {
×
77
          if (gattIsConnected()) break
×
78
          await this.device.gatt?.connect().catch(this.emitErr)
×
79
          await sleep(100)
×
80
        }
×
81
        if (!gattIsConnected()) throw new Error('Failed to connect gatt')
×
82

83
        const servs = new Map(_.map((await this.device?.gatt?.getPrimaryServices()) ?? [], serv => [toCanonicalUUID(serv.uuid), serv]))
×
84
        this.#debug(`gattServUuids = ${JSON.stringify([...servs.keys()])}`)
×
85

86
        const txStream = new this.TransformStream()
×
87
        const txStreamOnNotify = async (event: any): Promise<void> => {
×
88
          const dv = event?.target?.value
×
89
          if (!ArrayBuffer.isView(dv)) return
×
90
          const writer = txStream.writable.getWriter()
×
91
          if (_.isNil(writer)) throw new Error('Failed to get txStream writer')
×
92
          await writer.write(this.Buffer?.fromView(dv))
×
93
          writer.releaseLock()
×
94
        }
×
95
        if (servs.has(ULTRA_SERV_UUID)) {
×
96
          this.port = {
×
97
            isOpen: () => this.#isOpen,
×
98
            readable: txStream.readable,
×
99
            writable: new this.WritableStream(new UltraRxSink(this)),
×
100
          }
×
101
          const serv = servs.get(ULTRA_SERV_UUID)
×
102
          if (_.isNil(serv)) throw new Error(`Failed to find gatt serv, uuid = ${ULTRA_SERV_UUID}`)
×
103
          const chars = new Map(_.map((await serv.getCharacteristics()) ?? [], char => [toCanonicalUUID(char.uuid), char]))
×
104
          this.#debug(`gattCharUuids = ${JSON.stringify([...chars.keys()])}`)
×
105

106
          this.rxChar = chars.get(ULTRA_RX_CHAR_UUID) ?? null
×
107
          if (_.isNil(this.rxChar)) throw new Error(`Failed to find rxChar, uuid = ${ULTRA_TX_CHAR_UUID}`)
×
108
          const txChar = chars.get(ULTRA_TX_CHAR_UUID)
×
109
          if (_.isNil(txChar)) throw new Error(`Failed to find txChar, uuid = ${ULTRA_RX_CHAR_UUID}`)
×
110
          txChar.addEventListener('characteristicvaluechanged', txStreamOnNotify)
×
111
          await txChar.startNotifications()
×
112
          this.#isOpen = true
×
113
        } else if (servs.has(DFU_SERV_UUID)) {
×
114
          this.port = {
×
115
            isOpen: () => this.#isOpen,
×
116
            isDfu: () => true,
×
117
            readable: txStream.readable,
×
118
            writable: new this.WritableStream(new DfuRxSink(this)),
×
119
            dfuWriteObject: async (buf: Buffer, mtu?: number): Promise<void> => {
×
120
              if (_.isNil(this.packtChar) || _.isNil(this.Buffer)) throw new Error('this.#adapter.packtChar can not be null')
×
121
              let chunk: Buffer | undefined
×
122
              for (const buf1 of buf.chunk(20)) {
×
123
                if (chunk?.length !== buf1.length) chunk = new this.Buffer(buf1.length)
×
124
                chunk.set(buf1)
×
125
                await this.packtChar.writeValueWithoutResponse(chunk.buffer)
×
126
                await sleep(5) // wait for data to be processed
×
127
              }
×
128
            },
×
129
          }
×
130
          const serv = servs.get(DFU_SERV_UUID)
×
131
          if (_.isNil(serv)) throw new Error(`Failed to find gatt serv, uuid = ${DFU_SERV_UUID}`)
×
132
          const chars = new Map(_.map((await serv.getCharacteristics()) ?? [], char => [toCanonicalUUID(char.uuid), char]))
×
133
          this.#debug(`gattCharUuids = ${JSON.stringify([...chars.keys()])}`)
×
134

135
          this.packtChar = chars.get(DFU_PACKT_CHAR_UUID) ?? null
×
136
          if (_.isNil(this.packtChar)) throw new Error(`Failed to find packtChar, uuid = ${DFU_PACKT_CHAR_UUID}`)
×
137
          const ctrlChar = this.ctrlChar = chars.get(DFU_CTRL_CHAR_UUID) ?? null
×
138
          if (_.isNil(ctrlChar)) throw new Error(`Failed to find ctrlChar, uuid = ${DFU_CTRL_CHAR_UUID}`)
×
139
          ctrlChar.addEventListener('characteristicvaluechanged', txStreamOnNotify)
×
140
          await ctrlChar.startNotifications()
×
141
          this.#isOpen = true
×
142
        }
×
143

144
        if (!this.#isOpen) throw new Error('Failed to find supported service')
×
145
        ultra.port = this.port
×
146
        return await next()
×
147
      } catch (err) {
×
148
        this.emitErr(err)
×
149
        throw err
×
150
      }
×
151
    })
×
152

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

157
        await next().catch(this.emitErr)
×
158
        this.#isOpen = false
×
159
        if (gattIsConnected()) this.device.gatt?.disconnect()
×
160
        for (const k of ['port', 'rxChar', 'ctrlChar', 'packtChar', 'device'] as const) this[k] = null
×
161
      } catch (err) {
×
162
        this.emitErr(err)
×
163
        throw err
×
164
      }
×
165
    })
×
166

167
    return adapter
×
168
  }
×
169
}
×
170

171
setObject(globalThis, ['ChameleonUltraJS', 'WebbleAdapter'], WebbleAdapter)
×
172

173
/** @inline */
174
type AdapterInstallContext = PluginInstallContext & {
175
  ultra: PluginInstallContext['ultra'] & { $adapter?: any }
176
}
177

178
/** @inline */
179
interface AdapterInstallResp {
180
  isSupported: () => boolean
181
}
182

183
class UltraRxSink implements UnderlyingSink<Buffer> {
×
184
  readonly #adapter: WebbleAdapter
×
185
  Buffer: typeof Buffer
×
186

187
  constructor (adapter: WebbleAdapter) {
×
188
    this.#adapter = adapter
×
189
    if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null')
×
190
    this.Buffer = this.#adapter.Buffer
×
191
  }
×
192

193
  #debug (formatter: any, ...args: [] | any[]): void {
×
194
    this.#adapter.ultra?.emitter.emit('debug', 'webble', formatter, ...args)
×
195
  }
×
196

197
  async write (chunk: Buffer): Promise<void> {
×
198
    try {
×
199
      if (_.isNil(this.#adapter.rxChar)) throw new Error('this.#adapter.rxChar can not be null')
×
200

201
      // 20 bytes are left for the attribute data
202
      // https://stackoverflow.com/questions/38913743/maximum-packet-length-for-bluetooth-le
203
      let buf2: Buffer | null = null
×
204
      for (const buf1 of chunk.chunk(20)) {
×
205
        if (!this.Buffer.isBuffer(buf2) || buf1.length !== buf2.length) buf2 = new this.Buffer(buf1.length)
×
206
        buf2.set(buf1)
×
207
        this.#debug(`bleWrite = ${buf2.toString('hex')}`)
×
208
        await this.#adapter.rxChar.writeValueWithoutResponse(buf2.buffer)
×
209
      }
×
210
    } catch (err) {
×
211
      this.#adapter.emitErr(err)
×
212
      throw err
×
213
    }
×
214
  }
×
215
}
×
216

217
class DfuRxSink implements UnderlyingSink<Buffer> {
×
218
  readonly #adapter: WebbleAdapter
×
219
  Buffer: typeof Buffer
×
220

221
  constructor (adapter: WebbleAdapter) {
×
222
    this.#adapter = adapter
×
223
    if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null')
×
224
    this.Buffer = this.#adapter.Buffer
×
225
  }
×
226

227
  #debug (formatter: any, ...args: [] | any[]): void {
×
228
    this.#adapter.ultra?.emitter.emit('debug', 'webble', formatter, ...args)
×
229
  }
×
230

231
  async write (chunk: Buffer): Promise<void> {
×
232
    try {
×
233
      if (chunk.length !== chunk.buffer.byteLength) chunk = chunk.slice()
×
234
      if (_.isNil(this.#adapter.ctrlChar)) throw new Error('this.#adapter.ctrlChar can not be null')
×
235
      if (chunk.length > 20) throw new Error('chunk.length > 20 (BLE MTU)')
×
236
      this.#debug(`bleWrite = ${chunk.toString('hex')}`)
×
237
      await this.#adapter.ctrlChar.writeValueWithResponse(chunk.buffer)
×
238
    } catch (err) {
×
239
      this.#adapter.emitErr(err)
×
240
      throw err
×
241
    }
×
242
  }
×
243
}
×
244

245
type BluetoothServiceUUID = number | string
246

247
interface BluetoothManufacturerDataFilter<T = Buffer> extends BluetoothDataFilter<T> {
248
  companyIdentifier: number
249
}
250

251
interface BluetoothServiceDataFilter<T = Buffer> extends BluetoothDataFilter<T> {
252
  service: BluetoothServiceUUID
253
}
254

255
interface BluetoothDataFilter<T = Buffer> {
256
  readonly dataPrefix?: T | undefined
257
  readonly mask?: T | undefined
258
}
259

260
interface BluetoothLEScanFilter<T = Buffer> {
261
  readonly name?: string | undefined
262
  readonly namePrefix?: string | undefined
263
  readonly services?: BluetoothServiceUUID[] | undefined
264
  readonly manufacturerData?: Array<BluetoothManufacturerDataFilter<T>> | undefined
265
  readonly serviceData?: Array<BluetoothServiceDataFilter<T>> | undefined
266
}
267

268
function toCanonicalUUID (uuid: any): string {
×
269
  if (_.isString(uuid) && /^[0-9a-fA-F]{1,8}$/.test(uuid)) uuid = _.parseInt(uuid, 16)
×
270
  if (_.isSafeInteger(uuid)) uuid = BluetoothUUID.canonicalUUID(uuid)
×
271
  return _.toLower(uuid)
×
272
}
×
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