@hangtime/grip-connect 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +11 -0
  2. package/dist/cjs/index.d.ts +2 -2
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js +2 -1
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/interfaces/command.interface.d.ts +110 -21
  7. package/dist/cjs/interfaces/command.interface.d.ts.map +1 -1
  8. package/dist/cjs/interfaces/device/cts500.interface.d.ts +96 -0
  9. package/dist/cjs/interfaces/device/cts500.interface.d.ts.map +1 -0
  10. package/dist/cjs/interfaces/device/cts500.interface.js +3 -0
  11. package/dist/cjs/interfaces/device/cts500.interface.js.map +1 -0
  12. package/dist/cjs/interfaces/device/forceboard.interface.d.ts +2 -2
  13. package/dist/cjs/interfaces/device/forceboard.interface.d.ts.map +1 -1
  14. package/dist/cjs/interfaces/device/progressor.interface.d.ts +2 -2
  15. package/dist/cjs/interfaces/device/progressor.interface.d.ts.map +1 -1
  16. package/dist/cjs/interfaces/index.d.ts +2 -0
  17. package/dist/cjs/interfaces/index.d.ts.map +1 -1
  18. package/dist/cjs/interfaces/nordic.interface.d.ts +47 -0
  19. package/dist/cjs/interfaces/nordic.interface.d.ts.map +1 -0
  20. package/dist/cjs/interfaces/nordic.interface.js +3 -0
  21. package/dist/cjs/interfaces/nordic.interface.js.map +1 -0
  22. package/dist/cjs/models/device/cts500.model.d.ts +173 -0
  23. package/dist/cjs/models/device/cts500.model.d.ts.map +1 -0
  24. package/dist/cjs/models/device/cts500.model.js +588 -0
  25. package/dist/cjs/models/device/cts500.model.js.map +1 -0
  26. package/dist/cjs/models/device/forceboard.model.d.ts +2 -2
  27. package/dist/cjs/models/device/forceboard.model.d.ts.map +1 -1
  28. package/dist/cjs/models/device/forceboard.model.js +3 -14
  29. package/dist/cjs/models/device/forceboard.model.js.map +1 -1
  30. package/dist/cjs/models/device/progressor.model.d.ts +2 -2
  31. package/dist/cjs/models/device/progressor.model.d.ts.map +1 -1
  32. package/dist/cjs/models/device/progressor.model.js +3 -14
  33. package/dist/cjs/models/device/progressor.model.js.map +1 -1
  34. package/dist/cjs/models/device.model.d.ts +7 -0
  35. package/dist/cjs/models/device.model.d.ts.map +1 -1
  36. package/dist/cjs/models/device.model.js +23 -8
  37. package/dist/cjs/models/device.model.js.map +1 -1
  38. package/dist/cjs/models/index.d.ts +2 -0
  39. package/dist/cjs/models/index.d.ts.map +1 -1
  40. package/dist/cjs/models/index.js +6 -1
  41. package/dist/cjs/models/index.js.map +1 -1
  42. package/dist/cjs/models/nordic.model.d.ts +128 -0
  43. package/dist/cjs/models/nordic.model.d.ts.map +1 -0
  44. package/dist/cjs/models/nordic.model.js +405 -0
  45. package/dist/cjs/models/nordic.model.js.map +1 -0
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/interfaces/command.interface.d.ts +110 -21
  51. package/dist/interfaces/command.interface.d.ts.map +1 -1
  52. package/dist/interfaces/device/cts500.interface.d.ts +96 -0
  53. package/dist/interfaces/device/cts500.interface.d.ts.map +1 -0
  54. package/dist/interfaces/device/cts500.interface.js +2 -0
  55. package/dist/interfaces/device/cts500.interface.js.map +1 -0
  56. package/dist/interfaces/device/forceboard.interface.d.ts +2 -2
  57. package/dist/interfaces/device/forceboard.interface.d.ts.map +1 -1
  58. package/dist/interfaces/device/progressor.interface.d.ts +2 -2
  59. package/dist/interfaces/device/progressor.interface.d.ts.map +1 -1
  60. package/dist/interfaces/index.d.ts +2 -0
  61. package/dist/interfaces/index.d.ts.map +1 -1
  62. package/dist/interfaces/nordic.interface.d.ts +47 -0
  63. package/dist/interfaces/nordic.interface.d.ts.map +1 -0
  64. package/dist/interfaces/nordic.interface.js +2 -0
  65. package/dist/interfaces/nordic.interface.js.map +1 -0
  66. package/dist/models/device/cts500.model.d.ts +173 -0
  67. package/dist/models/device/cts500.model.d.ts.map +1 -0
  68. package/dist/models/device/cts500.model.js +584 -0
  69. package/dist/models/device/cts500.model.js.map +1 -0
  70. package/dist/models/device/forceboard.model.d.ts +2 -2
  71. package/dist/models/device/forceboard.model.d.ts.map +1 -1
  72. package/dist/models/device/forceboard.model.js +3 -14
  73. package/dist/models/device/forceboard.model.js.map +1 -1
  74. package/dist/models/device/progressor.model.d.ts +2 -2
  75. package/dist/models/device/progressor.model.d.ts.map +1 -1
  76. package/dist/models/device/progressor.model.js +3 -14
  77. package/dist/models/device/progressor.model.js.map +1 -1
  78. package/dist/models/device.model.d.ts +7 -0
  79. package/dist/models/device.model.d.ts.map +1 -1
  80. package/dist/models/device.model.js +22 -8
  81. package/dist/models/device.model.js.map +1 -1
  82. package/dist/models/index.d.ts +2 -0
  83. package/dist/models/index.d.ts.map +1 -1
  84. package/dist/models/index.js +2 -0
  85. package/dist/models/index.js.map +1 -1
  86. package/dist/models/nordic.model.d.ts +128 -0
  87. package/dist/models/nordic.model.d.ts.map +1 -0
  88. package/dist/models/nordic.model.js +393 -0
  89. package/dist/models/nordic.model.js.map +1 -0
  90. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  91. package/package.json +4 -3
  92. package/src/index.ts +2 -0
  93. package/src/interfaces/command.interface.ts +131 -21
  94. package/src/interfaces/device/cts500.interface.ts +113 -0
  95. package/src/interfaces/device/forceboard.interface.ts +2 -2
  96. package/src/interfaces/device/progressor.interface.ts +2 -2
  97. package/src/interfaces/index.ts +4 -0
  98. package/src/interfaces/nordic.interface.ts +47 -0
  99. package/src/models/device/cts500.model.ts +702 -0
  100. package/src/models/device/forceboard.model.ts +3 -14
  101. package/src/models/device/progressor.model.ts +3 -14
  102. package/src/models/device.model.ts +22 -8
  103. package/src/models/index.ts +4 -0
  104. package/src/models/nordic.model.ts +468 -0
@@ -1,11 +1,11 @@
1
- import { Device } from "../device.model.js"
1
+ import { NordicDfuDevice, createNordicDfuService } from "../nordic.model.js"
2
2
  import type { IForceBoard } from "../../interfaces/device/forceboard.interface.js"
3
3
 
4
4
  /**
5
5
  * Represents a PitchSix Force Board device.
6
6
  * {@link https://pitchsix.com}
7
7
  */
8
- export class ForceBoard extends Device implements IForceBoard {
8
+ export class ForceBoard extends NordicDfuDevice implements IForceBoard {
9
9
  protected override streamUnit = "lbs" as const
10
10
 
11
11
  constructor() {
@@ -46,18 +46,7 @@ export class ForceBoard extends Device implements IForceBoard {
46
46
  },
47
47
  ],
48
48
  },
49
- {
50
- name: "Nordic Device Firmware Update (DFU) Service",
51
- id: "dfu",
52
- uuid: "0000fe59-0000-1000-8000-00805f9b34fb",
53
- characteristics: [
54
- {
55
- name: "Buttonless DFU",
56
- id: "dfu",
57
- uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
58
- },
59
- ],
60
- },
49
+ createNordicDfuService(),
61
50
  {
62
51
  name: "",
63
52
  id: "",
@@ -1,4 +1,4 @@
1
- import { Device } from "../device.model.js"
1
+ import { NordicDfuDevice, createNordicDfuService } from "../nordic.model.js"
2
2
  import type { IProgressor } from "../../interfaces/device/progressor.interface.js"
3
3
 
4
4
  /**
@@ -113,7 +113,7 @@ function parseCalibrationTableRecordPayload(payload: Uint8Array, index: number):
113
113
  return `${String(index).padStart(2, "0")}: ${hex} | raw ${lowerRaw.toLocaleString()}..${upperRaw.toLocaleString()} | slope ${formatCalibrationFloat(slope)} | intercept ${formatCalibrationFloat(intercept)}`
114
114
  }
115
115
 
116
- export class Progressor extends Device implements IProgressor {
116
+ export class Progressor extends NordicDfuDevice implements IProgressor {
117
117
  /** Device timestamps (µs) of recent samples (samples in last 1s device time). */
118
118
  private recentSampleTimestamps: number[] = []
119
119
  /** 1-based index for multi-packet calibration-table export responses. */
@@ -140,18 +140,7 @@ export class Progressor extends Device implements IProgressor {
140
140
  },
141
141
  ],
142
142
  },
143
- {
144
- name: "Nordic Device Firmware Update (DFU) Service",
145
- id: "dfu",
146
- uuid: "0000fe59-0000-1000-8000-00805f9b34fb",
147
- characteristics: [
148
- {
149
- name: "Buttonless DFU",
150
- id: "dfu",
151
- uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
152
- },
153
- ],
154
- },
143
+ createNordicDfuService(),
155
144
  ],
156
145
  // Tindeq API: opcode = single byte (ASCII char code = decimal 100–114 v2 firmware: 115-118)
157
146
  commands: {
@@ -213,6 +213,14 @@ export abstract class Device extends BaseModel implements IDevice {
213
213
  */
214
214
  private tareActive = false
215
215
 
216
+ /**
217
+ * The characteristic ID that accepts notifications.
218
+ * Switching to "buttonless" allows to enter DFU mode and update the firmware.
219
+ *
220
+ * @type {"rx" | "buttonless" | "control"}
221
+ */
222
+ protected notifyCharacteristicId: "rx" | "buttonless" = "rx"
223
+
216
224
  /**
217
225
  * Timestamp when the tare calibration process started.
218
226
  * @type {number | null}
@@ -562,10 +570,12 @@ export abstract class Device extends BaseModel implements IDevice {
562
570
  // }
563
571
  // }
564
572
 
565
- this.bluetooth = await bluetooth.requestDevice({
566
- filters: this.filters,
567
- optionalServices: deviceServices,
568
- })
573
+ if (!this.bluetooth?.gatt) {
574
+ this.bluetooth = await bluetooth.requestDevice({
575
+ filters: this.filters,
576
+ optionalServices: deviceServices,
577
+ })
578
+ }
569
579
 
570
580
  if (!this.bluetooth.gatt) {
571
581
  throw new Error("GATT is not available on this device")
@@ -573,7 +583,7 @@ export abstract class Device extends BaseModel implements IDevice {
573
583
 
574
584
  this.bluetooth.addEventListener("gattserverdisconnected", this.onDisconnectedListener)
575
585
 
576
- this.server = await this.bluetooth.gatt.connect()
586
+ this.server = this.bluetooth.gatt.connected ? this.bluetooth.gatt : await this.bluetooth.gatt.connect()
577
587
 
578
588
  if (this.server.connected) {
579
589
  await this.onConnected(onSuccess)
@@ -604,7 +614,7 @@ export abstract class Device extends BaseModel implements IDevice {
604
614
  // Remove all notification listeners and stop notifications if possible.
605
615
  this.services.forEach((service) => {
606
616
  service.characteristics.forEach((char) => {
607
- if (!char.characteristic || char.id !== "rx") return
617
+ if (!char.characteristic || char.id !== this.notifyCharacteristicId) return
608
618
 
609
619
  if (isConnected) {
610
620
  // Best effort only: avoid unhandled rejections when the device already disconnected.
@@ -921,8 +931,8 @@ export abstract class Device extends BaseModel implements IDevice {
921
931
  if (descriptor) {
922
932
  // Assign the actual Bluetooth characteristic object to the descriptor so it can be used later
923
933
  descriptor.characteristic = matchingCharacteristic
924
- // Look for the "rx" characteristic id that accepts notifications
925
- if (descriptor.id === "rx") {
934
+ // Look for our default notify characteristic id that accepts notifications
935
+ if (descriptor.id === this.notifyCharacteristicId) {
926
936
  // Start receiving notifications for changes on this characteristic
927
937
  matchingCharacteristic.startNotifications()
928
938
  // Triggered when the characteristic's value changes
@@ -940,6 +950,10 @@ export abstract class Device extends BaseModel implements IDevice {
940
950
  this.notificationListeners.set(descriptor.uuid, listener)
941
951
  }
942
952
  }
953
+ } else if (matchingService.id === "dfu") {
954
+ // App mode exposes buttonless only, bootloader exposes control+packet only.
955
+ delete characteristic.characteristic
956
+ continue
943
957
  } else {
944
958
  throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`)
945
959
  }
@@ -1,9 +1,13 @@
1
1
  export { Climbro } from "./device/climbro.model.js"
2
2
 
3
+ export { CTS500 } from "./device/cts500.model.js"
4
+
3
5
  export { Entralpi } from "./device/entralpi.model.js"
4
6
 
5
7
  export { ForceBoard } from "./device/forceboard.model.js"
6
8
 
9
+ export { NordicDfuDevice, createNordicDfuService } from "./nordic.model.js"
10
+
7
11
  export { KilterBoard, KilterBoardPlacementRoles } from "./device/kilterboard.model.js"
8
12
 
9
13
  export { Motherboard } from "./device/motherboard.model.js"
@@ -0,0 +1,468 @@
1
+ import { Device } from "./device.model.js"
2
+ import type { Service } from "../interfaces/device.interface.js"
3
+ import type { INordicDfuDevice } from "../interfaces/nordic.interface.js"
4
+
5
+ const NORDIC_DFU_SERVICE_UUID = "0000fe59-0000-1000-8000-00805f9b34fb"
6
+ const DFU_PACKET_SIZE = 20
7
+ // Keep CRC values in signed int32 form so they compare directly with DataView.getInt32() responses from the bootloader.
8
+ const CRC32_TABLE = (() => {
9
+ const table = new Int32Array(256)
10
+
11
+ for (let index = 0; index < 256; index++) {
12
+ let crc = index
13
+ for (let bit = 0; bit < 8; bit++) {
14
+ crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
15
+ }
16
+ table[index] = crc | 0
17
+ }
18
+
19
+ return table
20
+ })()
21
+
22
+ /**
23
+ * Creates a fresh Nordic Secure DFU service definition.
24
+ * Characteristics are mutable at runtime, so each device instance needs its own copy.
25
+ * @returns {Service} A new DFU service descriptor with control, packet, and buttonless characteristics.
26
+ */
27
+ export function createNordicDfuService(): Service {
28
+ return {
29
+ name: "Nordic Device Firmware Update (DFU) Service",
30
+ id: "dfu",
31
+ uuid: NORDIC_DFU_SERVICE_UUID,
32
+ characteristics: [
33
+ {
34
+ name: "DFU Control Point",
35
+ id: "control",
36
+ uuid: "8ec90001-f315-4f60-9fb8-838830daea50",
37
+ },
38
+ {
39
+ name: "DFU Packet",
40
+ id: "packet",
41
+ uuid: "8ec90002-f315-4f60-9fb8-838830daea50",
42
+ },
43
+ {
44
+ name: "Buttonless DFU",
45
+ id: "buttonless",
46
+ uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
47
+ },
48
+ ],
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Shared Nordic Secure DFU implementation for devices exposing the FE59 service.
54
+ */
55
+ export abstract class NordicDfuDevice extends Device implements INordicDfuDevice {
56
+ /**
57
+ * Returns a cached DFU characteristic discovered during the current GATT session.
58
+ * @param {"control" | "packet" | "buttonless"} characteristicId - The DFU characteristic identifier.
59
+ * @returns {BluetoothRemoteGATTCharacteristic | undefined} The discovered characteristic, if available.
60
+ */
61
+ private getDfuCharacteristic(
62
+ characteristicId: "control" | "packet" | "buttonless",
63
+ ): BluetoothRemoteGATTCharacteristic | undefined {
64
+ return this.services
65
+ .find((service) => service.id === "dfu")
66
+ ?.characteristics.find((characteristic) => characteristic.id === characteristicId)?.characteristic
67
+ }
68
+
69
+ /**
70
+ * Checks whether the connected device is already exposing the DFU bootloader characteristics.
71
+ * @returns {boolean} `true` when both control and packet characteristics are available.
72
+ */
73
+ private hasDfuBootloaderCharacteristics(): boolean {
74
+ return this.getDfuCharacteristic("control") != null && this.getDfuCharacteristic("packet") != null
75
+ }
76
+
77
+ /**
78
+ * Wraps the shared connect flow so DFU callers get a rejected promise when connection setup fails.
79
+ * @returns {Promise<void>} Resolves when discovery is complete.
80
+ */
81
+ private async connectForDfu(): Promise<void> {
82
+ await new Promise<void>((resolve, reject) => {
83
+ void this.connect(
84
+ () => resolve(),
85
+ (error) => reject(error),
86
+ )
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Prompts for a Bluetooth device matching the provided filters, then runs the normal service discovery flow.
92
+ * @param {BluetoothLEScanFilter[]} filters - Alternative device filters to pass to `requestDevice`.
93
+ * @returns {Promise<void>} Resolves after the selected device is connected and characteristics are cached.
94
+ */
95
+ private async requestAndConnectDfuDevice(filters: BluetoothLEScanFilter[]): Promise<void> {
96
+ const bluetooth = await this.getBluetooth()
97
+ // Clear any stale GATT state before replacing the selected device with the bootloader identity.
98
+ this.disconnect()
99
+ delete this.bluetooth
100
+ this.bluetooth = await bluetooth.requestDevice({
101
+ filters,
102
+ optionalServices: this.getAllServiceUUIDs(),
103
+ })
104
+ await this.connectForDfu()
105
+ }
106
+
107
+ /**
108
+ * Ensures there is an active connection to either the application or DFU bootloader variant of the device.
109
+ * @returns {Promise<void>} Resolves after a DFU-capable device has been connected and discovered.
110
+ */
111
+ private async ensureDfuCapableConnection(): Promise<void> {
112
+ if (this.bluetooth?.gatt) {
113
+ try {
114
+ await this.connectForDfu()
115
+ return
116
+ } catch {
117
+ // If the previously granted device no longer reconnects, fall back to a fresh picker.
118
+ this.disconnect()
119
+ delete this.bluetooth
120
+ }
121
+ }
122
+
123
+ await this.requestAndConnectDfuDevice([...this.filters, { services: [NORDIC_DFU_SERVICE_UUID] }])
124
+ }
125
+
126
+ /**
127
+ * Prompts for the rebooted Nordic DFU bootloader after the application switches into buttonless DFU mode.
128
+ * @returns {Promise<void>} Resolves after the bootloader device is selected and connected.
129
+ */
130
+ private async connectDfuBootloader(): Promise<void> {
131
+ try {
132
+ await this.requestAndConnectDfuDevice([{ services: [NORDIC_DFU_SERVICE_UUID] }])
133
+ } catch (error) {
134
+ const message = error instanceof Error ? error.message : "Unknown error"
135
+ const wrappedError = new Error(
136
+ `Device entered DFU mode. Select the Nordic DFU bootloader to continue. ${message}`,
137
+ )
138
+ ;(wrappedError as Error & { cause?: unknown }).cause = error
139
+ throw wrappedError
140
+ }
141
+
142
+ if (!this.hasDfuBootloaderCharacteristics()) {
143
+ throw new Error("Selected device did not expose the Nordic DFU control and packet characteristics.")
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Normalizes DFU payload inputs to `Uint8Array` so packet slicing and CRC calculation use one byte representation.
149
+ * @param {Uint8Array | ArrayBuffer} data - Raw DFU payload bytes.
150
+ * @returns {Uint8Array} The payload as a `Uint8Array`.
151
+ */
152
+ private toDfuBytes(data: Uint8Array | ArrayBuffer): Uint8Array {
153
+ return data instanceof Uint8Array ? data : new Uint8Array(data)
154
+ }
155
+
156
+ /**
157
+ * Calculates the Nordic Secure DFU CRC32 for the given payload prefix.
158
+ * @param {Uint8Array} data - The bytes to checksum.
159
+ * @returns {number} The signed 32-bit CRC value returned by Nordic DFU checksum responses.
160
+ */
161
+ private dfuCrc32(data: Uint8Array): number {
162
+ let crc = 0xffffffff
163
+
164
+ for (const byte of data) {
165
+ const tableEntry = CRC32_TABLE[(crc ^ byte) & 0xff]
166
+ if (tableEntry === undefined) {
167
+ throw new Error("CRC32 lookup index out of range")
168
+ }
169
+ crc = tableEntry ^ (crc >>> 8)
170
+ }
171
+
172
+ return (crc ^ 0xffffffff) | 0
173
+ }
174
+
175
+ /**
176
+ * Formats a signed CRC value as an unsigned hexadecimal string for error messages.
177
+ * @param {number} crc - The CRC value to format.
178
+ * @returns {string} The CRC formatted as `0x????????`.
179
+ */
180
+ private formatDfuCrc(crc: number): string {
181
+ return `0x${(crc >>> 0).toString(16).padStart(8, "0")}`
182
+ }
183
+
184
+ /**
185
+ * Transfers one Nordic Secure DFU object type, handling resume, chunking, checksum validation, and execute steps.
186
+ * @param {"command" | "data"} objectType - The DFU object type to transfer.
187
+ * @param {Uint8Array | ArrayBuffer} data - The full payload for that object type.
188
+ * @returns {Promise<void>} Resolves when the payload has been fully transferred and validated.
189
+ */
190
+ private async dfuTransferObject(objectType: "command" | "data", data: Uint8Array | ArrayBuffer): Promise<void> {
191
+ const bytes = this.toDfuBytes(data)
192
+ if (bytes.byteLength === 0) {
193
+ throw new Error(`DFU ${objectType.toUpperCase()} payload is required`)
194
+ }
195
+
196
+ const { maxSize, offset, crc } = await this.dfuSelect(objectType)
197
+ if (maxSize <= 0) {
198
+ throw new Error(`DFU ${objectType.toUpperCase()} maxSize ${maxSize} is invalid`)
199
+ }
200
+ if (offset > bytes.byteLength) {
201
+ throw new Error(`DFU ${objectType.toUpperCase()} offset ${offset} exceeds payload size ${bytes.byteLength}`)
202
+ }
203
+ // Validate the bootloader's resume point before sending more bytes; otherwise a resumed transfer could continue from a corrupt state.
204
+ if (offset > 0) {
205
+ const expectedCrc = this.dfuCrc32(bytes.slice(0, offset))
206
+ if (expectedCrc !== crc) {
207
+ throw new Error(
208
+ `DFU ${objectType.toUpperCase()} resume CRC mismatch at offset ${offset}: expected ${this.formatDfuCrc(expectedCrc)}, got ${this.formatDfuCrc(crc)}`,
209
+ )
210
+ }
211
+ }
212
+ if (offset === bytes.byteLength) {
213
+ return
214
+ }
215
+
216
+ // The bootloader may report an offset in the middle of an object; restart from that object's boundary.
217
+ for (let objectStart = offset - (offset % maxSize); objectStart < bytes.byteLength; ) {
218
+ const objectEnd = Math.min(objectStart + maxSize, bytes.byteLength)
219
+ await this.dfuCreate(objectType, objectEnd - objectStart)
220
+
221
+ // Packet writes stay at 20 bytes for Web Bluetooth compatibility with the default ATT payload size.
222
+ for (let packetStart = objectStart; packetStart < objectEnd; packetStart += DFU_PACKET_SIZE) {
223
+ await this.dfuWritePacket(bytes.slice(packetStart, Math.min(packetStart + DFU_PACKET_SIZE, objectEnd)))
224
+ }
225
+
226
+ // Nordic reports checksum state for the whole transferred prefix, not just the current object chunk.
227
+ const state = await this.dfuChecksum()
228
+ if (state.offset !== objectEnd) {
229
+ throw new Error(`DFU ${objectType.toUpperCase()} checksum offset ${state.offset} did not match ${objectEnd}`)
230
+ }
231
+ const expectedCrc = this.dfuCrc32(bytes.slice(0, state.offset))
232
+ if (state.crc !== expectedCrc) {
233
+ throw new Error(
234
+ `DFU ${objectType.toUpperCase()} checksum CRC mismatch at offset ${state.offset}: expected ${this.formatDfuCrc(expectedCrc)}, got ${this.formatDfuCrc(state.crc)}`,
235
+ )
236
+ }
237
+
238
+ await this.dfuExecute()
239
+ objectStart = state.offset
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Switches the device from application mode into the Nordic DFU bootloader.
245
+ * @returns {Promise<void>} Resolves after the device reboots into DFU mode and reconnects to the bootloader.
246
+ */
247
+ dfuSwitch = async (): Promise<void> => {
248
+ // Reuse the existing connect/onConnected path, but subscribe to the buttonless DFU notifier.
249
+ this.notifyCharacteristicId = "buttonless"
250
+
251
+ try {
252
+ await this.ensureDfuCapableConnection()
253
+ if (this.hasDfuBootloaderCharacteristics()) {
254
+ return
255
+ }
256
+
257
+ if (!this.getDfuCharacteristic("buttonless")) {
258
+ throw new Error('Characteristic "buttonless" not found in service "dfu".')
259
+ }
260
+
261
+ const device = this.bluetooth
262
+ if (!device?.gatt?.connected) {
263
+ throw new Error("Device must be connected before entering DFU mode")
264
+ }
265
+
266
+ await new Promise<void>((resolve, reject) => {
267
+ const cleanup = (): void => {
268
+ device.removeEventListener("gattserverdisconnected", onDisconnected)
269
+ }
270
+
271
+ const onDisconnected = (): void => {
272
+ // Entering buttonless DFU reboots the device, so disconnect is the success signal here.
273
+ cleanup()
274
+ resolve()
275
+ }
276
+
277
+ device.addEventListener("gattserverdisconnected", onDisconnected, { once: true })
278
+
279
+ // Opcode 0x01 requests a switch from application mode into the Nordic DFU bootloader.
280
+ this.write("dfu", "buttonless", new Uint8Array([0x01])).catch((error) => {
281
+ cleanup()
282
+ reject(error)
283
+ })
284
+ })
285
+
286
+ // After the reboot, prompt for the bootloader explicitly instead of assuming the browser will reconnect to the same BLE identity.
287
+ await this.connectDfuBootloader()
288
+ } finally {
289
+ // Restore the normal application notify characteristic after the DFU transition attempt.
290
+ this.notifyCharacteristicId = "rx"
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Sends a raw Nordic Secure DFU control operation and resolves with the response payload bytes.
296
+ * Call after dfuSwitch() has reconnected to the DFU bootloader.
297
+ * @param {Uint8Array} operation - The DFU control opcode bytes to send.
298
+ * @param {ArrayBuffer} [payload] - Optional payload appended to the opcode.
299
+ * @returns {Promise<Uint8Array>} Resolves with the response payload bytes after the 3-byte Nordic response header.
300
+ */
301
+ dfuControl = async (operation: Uint8Array, payload?: ArrayBuffer): Promise<Uint8Array> => {
302
+ if (operation.length === 0) {
303
+ throw new Error("DFU control operation is required")
304
+ }
305
+
306
+ const control = this.getDfuCharacteristic("control")
307
+
308
+ if (!control) {
309
+ throw new Error('Characteristic "control" not found in service "dfu". Call dfuSwitch() first.')
310
+ }
311
+
312
+ const value = new Uint8Array(operation.length + (payload?.byteLength ?? 0))
313
+ value.set(operation)
314
+ if (payload) {
315
+ value.set(new Uint8Array(payload), operation.length)
316
+ }
317
+
318
+ await control.startNotifications()
319
+
320
+ return await new Promise<Uint8Array>((resolve, reject) => {
321
+ const cleanup = (): void => {
322
+ control.removeEventListener("characteristicvaluechanged", onNotification)
323
+ }
324
+
325
+ const onNotification = (event: Event): void => {
326
+ const target = event.target as BluetoothRemoteGATTCharacteristic
327
+ const view = target.value
328
+
329
+ // Control responses are framed as 0x60 <opcode> <status> [...payload].
330
+ if (!view || view.getUint8(0) !== 0x60 || view.getUint8(1) !== operation[0]) {
331
+ return
332
+ }
333
+
334
+ cleanup()
335
+
336
+ const status = view.getUint8(2)
337
+ if (status === 0x01) {
338
+ const response = new Uint8Array(view.buffer, view.byteOffset + 3, view.byteLength - 3)
339
+ resolve(Uint8Array.from(response))
340
+ return
341
+ }
342
+
343
+ if (status === 0x0b && view.byteLength > 3) {
344
+ reject(
345
+ new Error(`DFU control failed with extended error 0x${view.getUint8(3).toString(16).padStart(2, "0")}`),
346
+ )
347
+ return
348
+ }
349
+
350
+ reject(new Error(`DFU control failed with status 0x${status.toString(16).padStart(2, "0")}`))
351
+ }
352
+
353
+ control.addEventListener("characteristicvaluechanged", onNotification)
354
+
355
+ control.writeValue(value).catch((error) => {
356
+ cleanup()
357
+ reject(error)
358
+ })
359
+ })
360
+ }
361
+
362
+ /**
363
+ * Sends Nordic Secure DFU SELECT for command or data objects and returns the bootloader state.
364
+ * @param {"command" | "data"} objectType - The object type to query.
365
+ * @returns {Promise<{ maxSize: number; offset: number; crc: number }>} The bootloader's object size, offset, and CRC state.
366
+ */
367
+ dfuSelect = async (objectType: "command" | "data"): Promise<{ maxSize: number; offset: number; crc: number }> => {
368
+ const response = await this.dfuControl(new Uint8Array([0x06, objectType === "command" ? 0x01 : 0x02]))
369
+
370
+ if (response.byteLength < 12) {
371
+ throw new Error("DFU SELECT response was shorter than expected")
372
+ }
373
+
374
+ const view = new DataView(response.buffer, response.byteOffset, response.byteLength)
375
+ return {
376
+ maxSize: view.getUint32(0, true),
377
+ offset: view.getUint32(4, true),
378
+ crc: view.getInt32(8, true),
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Sends Nordic Secure DFU CREATE for command or data objects.
384
+ * @param {"command" | "data"} objectType - The object type to create.
385
+ * @param {number} size - The size of the object chunk to allocate in bytes.
386
+ * @returns {Promise<void>} Resolves when the bootloader accepts the object allocation request.
387
+ */
388
+ dfuCreate = async (objectType: "command" | "data", size: number): Promise<void> => {
389
+ if (!Number.isFinite(size) || size < 0) {
390
+ throw new Error("DFU CREATE size must be a non-negative number")
391
+ }
392
+
393
+ const payload = new ArrayBuffer(4)
394
+ new DataView(payload).setUint32(0, size, true)
395
+
396
+ await this.dfuControl(new Uint8Array([0x01, objectType === "command" ? 0x01 : 0x02]), payload)
397
+ }
398
+
399
+ /**
400
+ * Writes raw bytes to the Nordic Secure DFU packet characteristic.
401
+ * @param {Uint8Array | ArrayBuffer} data - The packet payload bytes to send.
402
+ * @returns {Promise<void>} Resolves after the packet has been written.
403
+ */
404
+ dfuWritePacket = async (data: Uint8Array | ArrayBuffer): Promise<void> => {
405
+ await this.write("dfu", "packet", data instanceof Uint8Array ? data : new Uint8Array(data), 0)
406
+ }
407
+
408
+ /**
409
+ * Sends Nordic Secure DFU CALCULATE_CHECKSUM and returns the bootloader state.
410
+ * @returns {Promise<{ offset: number; crc: number }>} The bootloader's transferred offset and CRC for the current object stream.
411
+ */
412
+ dfuChecksum = async (): Promise<{ offset: number; crc: number }> => {
413
+ const response = await this.dfuControl(new Uint8Array([0x03]))
414
+
415
+ if (response.byteLength < 8) {
416
+ throw new Error("DFU CHECKSUM response was shorter than expected")
417
+ }
418
+
419
+ const view = new DataView(response.buffer, response.byteOffset, response.byteLength)
420
+ return {
421
+ offset: view.getUint32(0, true),
422
+ crc: view.getInt32(4, true),
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Sends Nordic Secure DFU EXECUTE for the currently created object.
428
+ * @returns {Promise<void>} Resolves when the bootloader executes the current DFU object.
429
+ */
430
+ dfuExecute = async (): Promise<void> => {
431
+ await this.dfuControl(new Uint8Array([0x04]))
432
+ }
433
+
434
+ /**
435
+ * Runs a complete Nordic Secure DFU upload: switch to bootloader, send init packet, then send firmware.
436
+ * @param {Uint8Array | ArrayBuffer} initPacket - The Nordic Secure DFU init packet bytes.
437
+ * @param {Uint8Array | ArrayBuffer} firmware - The firmware image bytes to upload.
438
+ * @returns {Promise<void>} Resolves after the firmware upload completes and the bootloader disconnects to reboot.
439
+ */
440
+ dfuUpload = async (initPacket: Uint8Array | ArrayBuffer, firmware: Uint8Array | ArrayBuffer): Promise<void> => {
441
+ await this.dfuSwitch()
442
+ await this.dfuTransferObject("command", initPacket)
443
+
444
+ const device = this.bluetooth
445
+ if (!device?.gatt?.connected) {
446
+ throw new Error("Device disconnected before firmware transfer started")
447
+ }
448
+
449
+ // Attach the listener before the final data phase so a fast reboot cannot disconnect before we start waiting.
450
+ const waitForDisconnect = new Promise<void>((resolve) => {
451
+ const onDisconnected = (): void => {
452
+ device.removeEventListener("gattserverdisconnected", onDisconnected)
453
+ resolve()
454
+ }
455
+
456
+ device.addEventListener("gattserverdisconnected", onDisconnected, { once: true })
457
+ })
458
+
459
+ await this.dfuTransferObject("data", firmware)
460
+
461
+ // Some browsers observe the disconnect before the awaited transfer returns, so avoid waiting twice.
462
+ if (!device.gatt?.connected) {
463
+ return
464
+ }
465
+
466
+ await waitForDisconnect
467
+ }
468
+ }