@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.
- package/README.md +11 -0
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/interfaces/command.interface.d.ts +110 -21
- package/dist/cjs/interfaces/command.interface.d.ts.map +1 -1
- package/dist/cjs/interfaces/device/cts500.interface.d.ts +96 -0
- package/dist/cjs/interfaces/device/cts500.interface.d.ts.map +1 -0
- package/dist/cjs/interfaces/device/cts500.interface.js +3 -0
- package/dist/cjs/interfaces/device/cts500.interface.js.map +1 -0
- package/dist/cjs/interfaces/device/forceboard.interface.d.ts +2 -2
- package/dist/cjs/interfaces/device/forceboard.interface.d.ts.map +1 -1
- package/dist/cjs/interfaces/device/progressor.interface.d.ts +2 -2
- package/dist/cjs/interfaces/device/progressor.interface.d.ts.map +1 -1
- package/dist/cjs/interfaces/index.d.ts +2 -0
- package/dist/cjs/interfaces/index.d.ts.map +1 -1
- package/dist/cjs/interfaces/nordic.interface.d.ts +47 -0
- package/dist/cjs/interfaces/nordic.interface.d.ts.map +1 -0
- package/dist/cjs/interfaces/nordic.interface.js +3 -0
- package/dist/cjs/interfaces/nordic.interface.js.map +1 -0
- package/dist/cjs/models/device/cts500.model.d.ts +173 -0
- package/dist/cjs/models/device/cts500.model.d.ts.map +1 -0
- package/dist/cjs/models/device/cts500.model.js +588 -0
- package/dist/cjs/models/device/cts500.model.js.map +1 -0
- package/dist/cjs/models/device/forceboard.model.d.ts +2 -2
- package/dist/cjs/models/device/forceboard.model.d.ts.map +1 -1
- package/dist/cjs/models/device/forceboard.model.js +3 -14
- package/dist/cjs/models/device/forceboard.model.js.map +1 -1
- package/dist/cjs/models/device/progressor.model.d.ts +2 -2
- package/dist/cjs/models/device/progressor.model.d.ts.map +1 -1
- package/dist/cjs/models/device/progressor.model.js +3 -14
- package/dist/cjs/models/device/progressor.model.js.map +1 -1
- package/dist/cjs/models/device.model.d.ts +7 -0
- package/dist/cjs/models/device.model.d.ts.map +1 -1
- package/dist/cjs/models/device.model.js +23 -8
- package/dist/cjs/models/device.model.js.map +1 -1
- package/dist/cjs/models/index.d.ts +2 -0
- package/dist/cjs/models/index.d.ts.map +1 -1
- package/dist/cjs/models/index.js +6 -1
- package/dist/cjs/models/index.js.map +1 -1
- package/dist/cjs/models/nordic.model.d.ts +128 -0
- package/dist/cjs/models/nordic.model.d.ts.map +1 -0
- package/dist/cjs/models/nordic.model.js +405 -0
- package/dist/cjs/models/nordic.model.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/command.interface.d.ts +110 -21
- package/dist/interfaces/command.interface.d.ts.map +1 -1
- package/dist/interfaces/device/cts500.interface.d.ts +96 -0
- package/dist/interfaces/device/cts500.interface.d.ts.map +1 -0
- package/dist/interfaces/device/cts500.interface.js +2 -0
- package/dist/interfaces/device/cts500.interface.js.map +1 -0
- package/dist/interfaces/device/forceboard.interface.d.ts +2 -2
- package/dist/interfaces/device/forceboard.interface.d.ts.map +1 -1
- package/dist/interfaces/device/progressor.interface.d.ts +2 -2
- package/dist/interfaces/device/progressor.interface.d.ts.map +1 -1
- package/dist/interfaces/index.d.ts +2 -0
- package/dist/interfaces/index.d.ts.map +1 -1
- package/dist/interfaces/nordic.interface.d.ts +47 -0
- package/dist/interfaces/nordic.interface.d.ts.map +1 -0
- package/dist/interfaces/nordic.interface.js +2 -0
- package/dist/interfaces/nordic.interface.js.map +1 -0
- package/dist/models/device/cts500.model.d.ts +173 -0
- package/dist/models/device/cts500.model.d.ts.map +1 -0
- package/dist/models/device/cts500.model.js +584 -0
- package/dist/models/device/cts500.model.js.map +1 -0
- package/dist/models/device/forceboard.model.d.ts +2 -2
- package/dist/models/device/forceboard.model.d.ts.map +1 -1
- package/dist/models/device/forceboard.model.js +3 -14
- package/dist/models/device/forceboard.model.js.map +1 -1
- package/dist/models/device/progressor.model.d.ts +2 -2
- package/dist/models/device/progressor.model.d.ts.map +1 -1
- package/dist/models/device/progressor.model.js +3 -14
- package/dist/models/device/progressor.model.js.map +1 -1
- package/dist/models/device.model.d.ts +7 -0
- package/dist/models/device.model.d.ts.map +1 -1
- package/dist/models/device.model.js +22 -8
- package/dist/models/device.model.js.map +1 -1
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +2 -0
- package/dist/models/index.js.map +1 -1
- package/dist/models/nordic.model.d.ts +128 -0
- package/dist/models/nordic.model.d.ts.map +1 -0
- package/dist/models/nordic.model.js +393 -0
- package/dist/models/nordic.model.js.map +1 -0
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/index.ts +2 -0
- package/src/interfaces/command.interface.ts +131 -21
- package/src/interfaces/device/cts500.interface.ts +113 -0
- package/src/interfaces/device/forceboard.interface.ts +2 -2
- package/src/interfaces/device/progressor.interface.ts +2 -2
- package/src/interfaces/index.ts +4 -0
- package/src/interfaces/nordic.interface.ts +47 -0
- package/src/models/device/cts500.model.ts +702 -0
- package/src/models/device/forceboard.model.ts +3 -14
- package/src/models/device/progressor.model.ts +3 -14
- package/src/models/device.model.ts +22 -8
- package/src/models/index.ts +4 -0
- package/src/models/nordic.model.ts +468 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
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 !==
|
|
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
|
|
925
|
-
if (descriptor.id ===
|
|
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
|
}
|
package/src/models/index.ts
CHANGED
|
@@ -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
|
+
}
|