@hangtime/grip-connect
Version:
Griptonite Motherboard, Tindeq Progressor, PitchSix Force Board, CTS500, WHC-06, Entralpi, Climbro, mySmartBoard: Bluetooth API Force-Sensing strength analysis for climbers
393 lines • 18.9 kB
JavaScript
import { Device } from "./device.model.js";
const NORDIC_DFU_SERVICE_UUID = "0000fe59-0000-1000-8000-00805f9b34fb";
const DFU_PACKET_SIZE = 20;
// Keep CRC values in signed int32 form so they compare directly with DataView.getInt32() responses from the bootloader.
const CRC32_TABLE = (() => {
const table = new Int32Array(256);
for (let index = 0; index < 256; index++) {
let crc = index;
for (let bit = 0; bit < 8; bit++) {
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[index] = crc | 0;
}
return table;
})();
/**
* Creates a fresh Nordic Secure DFU service definition.
* Characteristics are mutable at runtime, so each device instance needs its own copy.
* @returns {Service} A new DFU service descriptor with control, packet, and buttonless characteristics.
*/
export function createNordicDfuService() {
return {
name: "Nordic Device Firmware Update (DFU) Service",
id: "dfu",
uuid: NORDIC_DFU_SERVICE_UUID,
characteristics: [
{
name: "DFU Control Point",
id: "control",
uuid: "8ec90001-f315-4f60-9fb8-838830daea50",
},
{
name: "DFU Packet",
id: "packet",
uuid: "8ec90002-f315-4f60-9fb8-838830daea50",
},
{
name: "Buttonless DFU",
id: "buttonless",
uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
},
],
};
}
/**
* Shared Nordic Secure DFU implementation for devices exposing the FE59 service.
*/
export class NordicDfuDevice extends Device {
/**
* Returns a cached DFU characteristic discovered during the current GATT session.
* @param {"control" | "packet" | "buttonless"} characteristicId - The DFU characteristic identifier.
* @returns {BluetoothRemoteGATTCharacteristic | undefined} The discovered characteristic, if available.
*/
getDfuCharacteristic(characteristicId) {
return this.services
.find((service) => service.id === "dfu")
?.characteristics.find((characteristic) => characteristic.id === characteristicId)?.characteristic;
}
/**
* Checks whether the connected device is already exposing the DFU bootloader characteristics.
* @returns {boolean} `true` when both control and packet characteristics are available.
*/
hasDfuBootloaderCharacteristics() {
return this.getDfuCharacteristic("control") != null && this.getDfuCharacteristic("packet") != null;
}
/**
* Wraps the shared connect flow so DFU callers get a rejected promise when connection setup fails.
* @returns {Promise<void>} Resolves when discovery is complete.
*/
async connectForDfu() {
await new Promise((resolve, reject) => {
void this.connect(() => resolve(), (error) => reject(error));
});
}
/**
* Prompts for a Bluetooth device matching the provided filters, then runs the normal service discovery flow.
* @param {BluetoothLEScanFilter[]} filters - Alternative device filters to pass to `requestDevice`.
* @returns {Promise<void>} Resolves after the selected device is connected and characteristics are cached.
*/
async requestAndConnectDfuDevice(filters) {
const bluetooth = await this.getBluetooth();
// Clear any stale GATT state before replacing the selected device with the bootloader identity.
this.disconnect();
delete this.bluetooth;
this.bluetooth = await bluetooth.requestDevice({
filters,
optionalServices: this.getAllServiceUUIDs(),
});
await this.connectForDfu();
}
/**
* Ensures there is an active connection to either the application or DFU bootloader variant of the device.
* @returns {Promise<void>} Resolves after a DFU-capable device has been connected and discovered.
*/
async ensureDfuCapableConnection() {
if (this.bluetooth?.gatt) {
try {
await this.connectForDfu();
return;
}
catch {
// If the previously granted device no longer reconnects, fall back to a fresh picker.
this.disconnect();
delete this.bluetooth;
}
}
await this.requestAndConnectDfuDevice([...this.filters, { services: [NORDIC_DFU_SERVICE_UUID] }]);
}
/**
* Prompts for the rebooted Nordic DFU bootloader after the application switches into buttonless DFU mode.
* @returns {Promise<void>} Resolves after the bootloader device is selected and connected.
*/
async connectDfuBootloader() {
try {
await this.requestAndConnectDfuDevice([{ services: [NORDIC_DFU_SERVICE_UUID] }]);
}
catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
const wrappedError = new Error(`Device entered DFU mode. Select the Nordic DFU bootloader to continue. ${message}`);
wrappedError.cause = error;
throw wrappedError;
}
if (!this.hasDfuBootloaderCharacteristics()) {
throw new Error("Selected device did not expose the Nordic DFU control and packet characteristics.");
}
}
/**
* Normalizes DFU payload inputs to `Uint8Array` so packet slicing and CRC calculation use one byte representation.
* @param {Uint8Array | ArrayBuffer} data - Raw DFU payload bytes.
* @returns {Uint8Array} The payload as a `Uint8Array`.
*/
toDfuBytes(data) {
return data instanceof Uint8Array ? data : new Uint8Array(data);
}
/**
* Calculates the Nordic Secure DFU CRC32 for the given payload prefix.
* @param {Uint8Array} data - The bytes to checksum.
* @returns {number} The signed 32-bit CRC value returned by Nordic DFU checksum responses.
*/
dfuCrc32(data) {
let crc = 0xffffffff;
for (const byte of data) {
const tableEntry = CRC32_TABLE[(crc ^ byte) & 0xff];
if (tableEntry === undefined) {
throw new Error("CRC32 lookup index out of range");
}
crc = tableEntry ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) | 0;
}
/**
* Formats a signed CRC value as an unsigned hexadecimal string for error messages.
* @param {number} crc - The CRC value to format.
* @returns {string} The CRC formatted as `0x????????`.
*/
formatDfuCrc(crc) {
return `0x${(crc >>> 0).toString(16).padStart(8, "0")}`;
}
/**
* Transfers one Nordic Secure DFU object type, handling resume, chunking, checksum validation, and execute steps.
* @param {"command" | "data"} objectType - The DFU object type to transfer.
* @param {Uint8Array | ArrayBuffer} data - The full payload for that object type.
* @returns {Promise<void>} Resolves when the payload has been fully transferred and validated.
*/
async dfuTransferObject(objectType, data) {
const bytes = this.toDfuBytes(data);
if (bytes.byteLength === 0) {
throw new Error(`DFU ${objectType.toUpperCase()} payload is required`);
}
const { maxSize, offset, crc } = await this.dfuSelect(objectType);
if (maxSize <= 0) {
throw new Error(`DFU ${objectType.toUpperCase()} maxSize ${maxSize} is invalid`);
}
if (offset > bytes.byteLength) {
throw new Error(`DFU ${objectType.toUpperCase()} offset ${offset} exceeds payload size ${bytes.byteLength}`);
}
// Validate the bootloader's resume point before sending more bytes; otherwise a resumed transfer could continue from a corrupt state.
if (offset > 0) {
const expectedCrc = this.dfuCrc32(bytes.slice(0, offset));
if (expectedCrc !== crc) {
throw new Error(`DFU ${objectType.toUpperCase()} resume CRC mismatch at offset ${offset}: expected ${this.formatDfuCrc(expectedCrc)}, got ${this.formatDfuCrc(crc)}`);
}
}
if (offset === bytes.byteLength) {
return;
}
// The bootloader may report an offset in the middle of an object; restart from that object's boundary.
for (let objectStart = offset - (offset % maxSize); objectStart < bytes.byteLength;) {
const objectEnd = Math.min(objectStart + maxSize, bytes.byteLength);
await this.dfuCreate(objectType, objectEnd - objectStart);
// Packet writes stay at 20 bytes for Web Bluetooth compatibility with the default ATT payload size.
for (let packetStart = objectStart; packetStart < objectEnd; packetStart += DFU_PACKET_SIZE) {
await this.dfuWritePacket(bytes.slice(packetStart, Math.min(packetStart + DFU_PACKET_SIZE, objectEnd)));
}
// Nordic reports checksum state for the whole transferred prefix, not just the current object chunk.
const state = await this.dfuChecksum();
if (state.offset !== objectEnd) {
throw new Error(`DFU ${objectType.toUpperCase()} checksum offset ${state.offset} did not match ${objectEnd}`);
}
const expectedCrc = this.dfuCrc32(bytes.slice(0, state.offset));
if (state.crc !== expectedCrc) {
throw new Error(`DFU ${objectType.toUpperCase()} checksum CRC mismatch at offset ${state.offset}: expected ${this.formatDfuCrc(expectedCrc)}, got ${this.formatDfuCrc(state.crc)}`);
}
await this.dfuExecute();
objectStart = state.offset;
}
}
/**
* Switches the device from application mode into the Nordic DFU bootloader.
* @returns {Promise<void>} Resolves after the device reboots into DFU mode and reconnects to the bootloader.
*/
dfuSwitch = async () => {
// Reuse the existing connect/onConnected path, but subscribe to the buttonless DFU notifier.
this.notifyCharacteristicId = "buttonless";
try {
await this.ensureDfuCapableConnection();
if (this.hasDfuBootloaderCharacteristics()) {
return;
}
if (!this.getDfuCharacteristic("buttonless")) {
throw new Error('Characteristic "buttonless" not found in service "dfu".');
}
const device = this.bluetooth;
if (!device?.gatt?.connected) {
throw new Error("Device must be connected before entering DFU mode");
}
await new Promise((resolve, reject) => {
const cleanup = () => {
device.removeEventListener("gattserverdisconnected", onDisconnected);
};
const onDisconnected = () => {
// Entering buttonless DFU reboots the device, so disconnect is the success signal here.
cleanup();
resolve();
};
device.addEventListener("gattserverdisconnected", onDisconnected, { once: true });
// Opcode 0x01 requests a switch from application mode into the Nordic DFU bootloader.
this.write("dfu", "buttonless", new Uint8Array([0x01])).catch((error) => {
cleanup();
reject(error);
});
});
// After the reboot, prompt for the bootloader explicitly instead of assuming the browser will reconnect to the same BLE identity.
await this.connectDfuBootloader();
}
finally {
// Restore the normal application notify characteristic after the DFU transition attempt.
this.notifyCharacteristicId = "rx";
}
};
/**
* Sends a raw Nordic Secure DFU control operation and resolves with the response payload bytes.
* Call after dfuSwitch() has reconnected to the DFU bootloader.
* @param {Uint8Array} operation - The DFU control opcode bytes to send.
* @param {ArrayBuffer} [payload] - Optional payload appended to the opcode.
* @returns {Promise<Uint8Array>} Resolves with the response payload bytes after the 3-byte Nordic response header.
*/
dfuControl = async (operation, payload) => {
if (operation.length === 0) {
throw new Error("DFU control operation is required");
}
const control = this.getDfuCharacteristic("control");
if (!control) {
throw new Error('Characteristic "control" not found in service "dfu". Call dfuSwitch() first.');
}
const value = new Uint8Array(operation.length + (payload?.byteLength ?? 0));
value.set(operation);
if (payload) {
value.set(new Uint8Array(payload), operation.length);
}
await control.startNotifications();
return await new Promise((resolve, reject) => {
const cleanup = () => {
control.removeEventListener("characteristicvaluechanged", onNotification);
};
const onNotification = (event) => {
const target = event.target;
const view = target.value;
// Control responses are framed as 0x60 <opcode> <status> [...payload].
if (!view || view.getUint8(0) !== 0x60 || view.getUint8(1) !== operation[0]) {
return;
}
cleanup();
const status = view.getUint8(2);
if (status === 0x01) {
const response = new Uint8Array(view.buffer, view.byteOffset + 3, view.byteLength - 3);
resolve(Uint8Array.from(response));
return;
}
if (status === 0x0b && view.byteLength > 3) {
reject(new Error(`DFU control failed with extended error 0x${view.getUint8(3).toString(16).padStart(2, "0")}`));
return;
}
reject(new Error(`DFU control failed with status 0x${status.toString(16).padStart(2, "0")}`));
};
control.addEventListener("characteristicvaluechanged", onNotification);
control.writeValue(value).catch((error) => {
cleanup();
reject(error);
});
});
};
/**
* Sends Nordic Secure DFU SELECT for command or data objects and returns the bootloader state.
* @param {"command" | "data"} objectType - The object type to query.
* @returns {Promise<{ maxSize: number; offset: number; crc: number }>} The bootloader's object size, offset, and CRC state.
*/
dfuSelect = async (objectType) => {
const response = await this.dfuControl(new Uint8Array([0x06, objectType === "command" ? 0x01 : 0x02]));
if (response.byteLength < 12) {
throw new Error("DFU SELECT response was shorter than expected");
}
const view = new DataView(response.buffer, response.byteOffset, response.byteLength);
return {
maxSize: view.getUint32(0, true),
offset: view.getUint32(4, true),
crc: view.getInt32(8, true),
};
};
/**
* Sends Nordic Secure DFU CREATE for command or data objects.
* @param {"command" | "data"} objectType - The object type to create.
* @param {number} size - The size of the object chunk to allocate in bytes.
* @returns {Promise<void>} Resolves when the bootloader accepts the object allocation request.
*/
dfuCreate = async (objectType, size) => {
if (!Number.isFinite(size) || size < 0) {
throw new Error("DFU CREATE size must be a non-negative number");
}
const payload = new ArrayBuffer(4);
new DataView(payload).setUint32(0, size, true);
await this.dfuControl(new Uint8Array([0x01, objectType === "command" ? 0x01 : 0x02]), payload);
};
/**
* Writes raw bytes to the Nordic Secure DFU packet characteristic.
* @param {Uint8Array | ArrayBuffer} data - The packet payload bytes to send.
* @returns {Promise<void>} Resolves after the packet has been written.
*/
dfuWritePacket = async (data) => {
await this.write("dfu", "packet", data instanceof Uint8Array ? data : new Uint8Array(data), 0);
};
/**
* Sends Nordic Secure DFU CALCULATE_CHECKSUM and returns the bootloader state.
* @returns {Promise<{ offset: number; crc: number }>} The bootloader's transferred offset and CRC for the current object stream.
*/
dfuChecksum = async () => {
const response = await this.dfuControl(new Uint8Array([0x03]));
if (response.byteLength < 8) {
throw new Error("DFU CHECKSUM response was shorter than expected");
}
const view = new DataView(response.buffer, response.byteOffset, response.byteLength);
return {
offset: view.getUint32(0, true),
crc: view.getInt32(4, true),
};
};
/**
* Sends Nordic Secure DFU EXECUTE for the currently created object.
* @returns {Promise<void>} Resolves when the bootloader executes the current DFU object.
*/
dfuExecute = async () => {
await this.dfuControl(new Uint8Array([0x04]));
};
/**
* Runs a complete Nordic Secure DFU upload: switch to bootloader, send init packet, then send firmware.
* @param {Uint8Array | ArrayBuffer} initPacket - The Nordic Secure DFU init packet bytes.
* @param {Uint8Array | ArrayBuffer} firmware - The firmware image bytes to upload.
* @returns {Promise<void>} Resolves after the firmware upload completes and the bootloader disconnects to reboot.
*/
dfuUpload = async (initPacket, firmware) => {
await this.dfuSwitch();
await this.dfuTransferObject("command", initPacket);
const device = this.bluetooth;
if (!device?.gatt?.connected) {
throw new Error("Device disconnected before firmware transfer started");
}
// Attach the listener before the final data phase so a fast reboot cannot disconnect before we start waiting.
const waitForDisconnect = new Promise((resolve) => {
const onDisconnected = () => {
device.removeEventListener("gattserverdisconnected", onDisconnected);
resolve();
};
device.addEventListener("gattserverdisconnected", onDisconnected, { once: true });
});
await this.dfuTransferObject("data", firmware);
// Some browsers observe the disconnect before the awaited transfer returns, so avoid waiting twice.
if (!device.gatt?.connected) {
return;
}
await waitForDisconnect;
};
}
//# sourceMappingURL=nordic.model.js.map