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

taichunmin / chameleon-ultra.js / 16902739519

12 Aug 2025 08:00AM UTC coverage: 68.124% (-1.1%) from 69.192%
16902739519

push

github

web-flow
Add support 9 new cmds (#204)

504 of 658 branches covered (76.6%)

Branch coverage included in aggregate %.

415 of 589 new or added lines in 10 files covered. (70.46%)

9 existing lines in 3 files now uncovered.

2815 of 4214 relevant lines covered (66.8%)

2958829.46 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'
2
import * as _ from 'lodash-es'
×
3
import { TransformStream, type UnderlyingSink, WritableStream } from 'stream/web'
×
4
import { type bluetooth } from 'webbluetooth'
5
import { type ChameleonUltra } from '../ChameleonUltra'
6
import { sleep } from '../helper'
×
7
import { setObject } from '../iifeExportHelper'
×
8
import { type AdapterInstallResp, type UltraPlugin, type UltraSerialPort, type PluginInstallContext } from '../types'
9

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

168
    return adapter
×
169
  }
×
170
}
×
171

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

UNCOV
174
class UltraRxSink implements UnderlyingSink<Buffer> {
×
UNCOV
175
  readonly #adapter: WebbleAdapter
×
176
  Buffer: typeof Buffer
×
177

178
  constructor (adapter: WebbleAdapter) {
×
179
    this.#adapter = adapter
×
180
    if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null')
×
181
    this.Buffer = this.#adapter.Buffer
×
182
  }
×
183

184
  #debug (formatter: any, ...args: [] | any[]): void {
×
185
    this.#adapter.ultra?.emitter.emit('debug', 'webble', formatter, ...args)
×
186
  }
×
187

188
  async write (chunk: Buffer): Promise<void> {
×
189
    try {
×
190
      if (_.isNil(this.#adapter.rxChar)) throw new Error('this.#adapter.rxChar can not be null')
×
191

192
      // 20 bytes are left for the attribute data
193
      // https://stackoverflow.com/questions/38913743/maximum-packet-length-for-bluetooth-le
194
      let buf2: Buffer | null = null
×
195
      for (const buf1 of chunk.chunk(20)) {
×
196
        if (!this.Buffer.isBuffer(buf2) || buf1.length !== buf2.length) buf2 = new this.Buffer(buf1.length)
×
197
        buf2.set(buf1)
×
198
        this.#debug(`bleWrite = ${buf2.toString('hex')}`)
×
199
        await this.#adapter.rxChar.writeValueWithoutResponse(buf2.buffer)
×
200
      }
×
201
    } catch (err) {
×
202
      this.#adapter.emitErr(err)
×
203
      throw err
×
204
    }
×
205
  }
×
206
}
×
207

208
class DfuRxSink implements UnderlyingSink<Buffer> {
×
209
  readonly #adapter: WebbleAdapter
×
210
  Buffer: typeof Buffer
×
211

212
  constructor (adapter: WebbleAdapter) {
×
213
    this.#adapter = adapter
×
214
    if (_.isNil(this.#adapter.Buffer)) throw new Error('this.#adapter.Buffer can not be null')
×
215
    this.Buffer = this.#adapter.Buffer
×
216
  }
×
217

218
  #debug (formatter: any, ...args: [] | any[]): void {
×
219
    this.#adapter.ultra?.emitter.emit('debug', 'webble', formatter, ...args)
×
220
  }
×
221

222
  async write (chunk: Buffer): Promise<void> {
×
223
    try {
×
224
      if (chunk.length !== chunk.buffer.byteLength) chunk = chunk.slice()
×
225
      if (_.isNil(this.#adapter.ctrlChar)) throw new Error('this.#adapter.ctrlChar can not be null')
×
226
      if (chunk.length > 20) throw new Error('chunk.length > 20 (BLE MTU)')
×
227
      this.#debug(`bleWrite = ${chunk.toString('hex')}`)
×
228
      await this.#adapter.ctrlChar.writeValueWithResponse(chunk.buffer)
×
229
    } catch (err) {
×
230
      this.#adapter.emitErr(err)
×
231
      throw err
×
232
    }
×
233
  }
×
234
}
×
235

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