UNPKG

@hangtime/grip-connect

Version:

Griptonite Motherboard, Tindeq Progressor, PitchSix Force Board, WHC-06, Entralpi, Climbro, mySmartBoard: Bluetooth API Force-Sensing strength analysis for climbers

417 lines (379 loc) 14.9 kB
import { Device } from "../device.model.js" import type { IMotherboard } from "../../interfaces/device/motherboard.interface.js" import type { DownloadPacket } from "../../interfaces/download.interface.js" /** * Represents a Griptonite Motherboard device. * {@link https://griptonite.io} */ export class Motherboard extends Device implements IMotherboard { /** * Length of the packet received from the device. * @type {number} * @static * @readonly * @constant */ private static readonly packetLength: number = 32 /** * Number of samples contained in the data packet. * @type {number} * @static * @readonly * @constant */ private static readonly samplesNumber: number = 3 /** * Buffer to store received data from the device. * @type {number[]} * @private */ private receiveBuffer: number[] = [] /** * Calibration data for each sensor of the device. * @type {number[][][]} * @private */ private calibrationData: number[][][] = [[], [], [], []] constructor() { super({ filters: [{ name: "Motherboard" }], services: [ { name: "Device Information", id: "device", uuid: "0000180a-0000-1000-8000-00805f9b34fb", characteristics: [ // { // name: 'Serial Number String (Blocked)', // id: 'serial' // uuid: '00002a25-0000-1000-8000-00805f9b34fb' // }, { name: "Firmware Revision String", id: "firmware", uuid: "00002a26-0000-1000-8000-00805f9b34fb", }, { name: "Hardware Revision String", id: "hardware", uuid: "00002a27-0000-1000-8000-00805f9b34fb", }, { name: "Manufacturer Name String", id: "manufacturer", uuid: "00002a29-0000-1000-8000-00805f9b34fb", }, ], }, { name: "Battery Service", id: "battery", uuid: "0000180f-0000-1000-8000-00805f9b34fb", characteristics: [ { name: "Battery Level", id: "level", uuid: "00002a19-0000-1000-8000-00805f9b34fb", }, ], }, { name: "LED Service", id: "led", uuid: "10ababcd-15e1-28ff-de13-725bea03b127", characteristics: [ { name: "Red LED", id: "red", uuid: "10ab1524-15e1-28ff-de13-725bea03b127", }, { name: "Green LED", id: "green", uuid: "10ab1525-15e1-28ff-de13-725bea03b127", }, ], }, { name: "UART Nordic Service", id: "uart", uuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", characteristics: [ { name: "TX", id: "tx", uuid: "6e400002-b5a3-f393-e0a9-e50e24dcca9e", }, { name: "RX", id: "rx", uuid: "6e400003-b5a3-f393-e0a9-e50e24dcca9e", }, ], }, ], commands: { GET_SERIAL: "#", START_WEIGHT_MEAS: "S30", STOP_WEIGHT_MEAS: "", // All commands will stop the data stream. GET_CALIBRATION: "C", SLEEP: 0, GET_TEXT: "T", DEBUG_STREAM: "D", }, }) } /** * Applies calibration to a sample value. * @param {number} sample - The sample value to calibrate. * @param {number[][]} calibration - The calibration data. * @returns {number} The calibrated sample value. */ private applyCalibration = (sample: number, calibration: number[][]): number => { // Extract the calibrated value for the zero point const zeroCalibration: number = calibration[0][2] // Initialize sign as positive let sign = 1 // Initialize the final calibrated value let final = 0 // If the sample value is less than the zero calibration point if (sample < zeroCalibration) { // Change the sign to negative sign = -1 // Reflect the sample around the zero calibration point sample = /* 2 * zeroCalibration */ -sample } // Iterate through the calibration data for (let i = 1; i < calibration.length; i++) { // Extract the lower and upper bounds of the current calibration range const calibrationStart: number = calibration[i - 1][2] const calibrationEnd: number = calibration[i][2] // If the sample value is within the current calibration range if (sample < calibrationEnd) { // Interpolate to get the calibrated value within the range final = calibration[i - 1][1] + ((sample - calibrationStart) / (calibrationEnd - calibrationStart)) * (calibration[i][1] - calibration[i - 1][1]) break } } // Return the calibrated value with the appropriate sign (positive/negative) return sign * final } /** * Retrieves battery or voltage information from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the battery or voltage information, */ battery = async (): Promise<string | undefined> => { return await this.read("battery", "level", 250) } /** * Writes a command to get calibration data from the device. * @returns {Promise<void>} A Promise that resolves when the command is successfully sent. */ calibration = async (): Promise<void> => { await this.write("uart", "tx", this.commands.GET_CALIBRATION, 2500, (data) => { console.log(data) }) } /** * Retrieves firmware version from the device. * @returns {Promise<string>} A Promise that resolves with the firmware version, */ firmware = async (): Promise<string | undefined> => { return await this.read("device", "firmware", 250) } /** * Handles data received from the Motherboard device. Processes hex-encoded streaming packets * to extract samples, calibrate masses, and update running averages of mass data. * If the received data is not a valid hex packet, it returns the unprocessed data. * * @param {DataView} value - The notification event. */ override handleNotifications = (value: DataView): void => { if (value) { // Update timestamp this.updateTimestamp() if (value.buffer) { for (let i = 0; i < value.byteLength; i++) { this.receiveBuffer.push(value.getUint8(i)) } let idx: number while ((idx = this.receiveBuffer.indexOf(10)) >= 0) { const line: number[] = this.receiveBuffer.splice(0, idx + 1).slice(0, -1) // Combine and remove LF if (line.length > 0 && line[line.length - 1] === 13) line.pop() // Remove CR const decoder: TextDecoder = new TextDecoder("utf-8") const receivedData: string = decoder.decode(new Uint8Array(line)) const receivedTime: number = Date.now() // Check if the line is entirely hex characters const isAllHex: boolean = /^[0-9A-Fa-f]+$/g.test(receivedData) // Handle streaming packet if (isAllHex && receivedData.length === Motherboard.packetLength) { // Base-16 decode the string: convert hex pairs to byte values const bytes: number[] = Array.from({ length: receivedData.length / 2 }, (_, i) => Number(`0x${receivedData.substring(i * 2, i * 2 + 2)}`), ) // Translate header into packet, number of samples from the packet length const packet: DownloadPacket = { received: receivedTime, sampleNum: new DataView(new Uint8Array(bytes).buffer).getUint16(0, true), battRaw: new DataView(new Uint8Array(bytes).buffer).getUint16(2, true), samples: [], masses: [], } const dataView = new DataView(new Uint8Array(bytes).buffer) for (let i = 0; i < Motherboard.samplesNumber; i++) { const sampleStart: number = 4 + 3 * i // Use DataView to read the 24-bit unsigned integer const rawValue = dataView.getUint8(sampleStart) | (dataView.getUint8(sampleStart + 1) << 8) | (dataView.getUint8(sampleStart + 2) << 16) // Ensure unsigned 32-bit integer packet.samples[i] = rawValue >>> 0 if (packet.samples[i] >= 0x7fffff) { packet.samples[i] -= 0x1000000 } packet.masses[i] = this.applyCalibration(packet.samples[i], this.calibrationData[i]) } // invert center and right values packet.masses[1] *= -1 packet.masses[2] *= -1 // Add data to downloadable Array this.downloadPackets.push({ received: packet.received, sampleNum: packet.battRaw, battRaw: packet.received, samples: [...packet.samples], masses: [...packet.masses], }) let left: number = packet.masses[0] let center: number = packet.masses[1] let right: number = packet.masses[2] // Tare correction left -= this.applyTare(left) center -= this.applyTare(center) right -= this.applyTare(right) this.massMax = Math.max(Number(this.massMax), Math.max(-1000, left + center + right)).toFixed(1) // Update running sum and count const currentMassTotal = Math.max(-1000, left + center + right) this.massTotalSum += currentMassTotal this.dataPointCount++ // Calculate the average dynamically this.massAverage = (this.massTotalSum / this.dataPointCount).toFixed(1) // Check if device is being used this.activityCheck(center) // Notify with weight data this.notifyCallback({ massTotal: Math.max(-1000, left + center + right).toFixed(1), massMax: this.massMax, massAverage: this.massAverage, massLeft: Math.max(-1000, packet.masses[0]).toFixed(1), massCenter: Math.max(-1000, packet.masses[1]).toFixed(1), massRight: Math.max(-1000, packet.masses[2]).toFixed(1), }) } else if (this.writeLast === this.commands.GET_CALIBRATION) { // check data integrity if ((receivedData.match(/,/g) || []).length === 3) { const parts: string[] = receivedData.split(",") const numericParts: number[] = parts.map((x) => parseFloat(x)) ;(this.calibrationData[numericParts[0]] as number[][]).push(numericParts.slice(1)) } } else { // unhandled data this.writeCallback(receivedData) } } } } } /** * Retrieves hardware version from the device. * @returns {Promise<string>} A Promise that resolves with the hardware version, */ hardware = async (): Promise<string | undefined> => { return await this.read("device", "hardware", 250) } /** * Sets the LED color based on a single color option. Defaults to turning the LEDs off if no configuration is provided. * @param {"green" | "red" | "orange"} [config] - Optional color or array of climb placements for the LEDs. Ignored if placements are provided. * @returns {Promise<number[] | undefined>} A promise that resolves with the payload array for the Kilter Board if LED settings were applied, or `undefined` if no action was taken or for the Motherboard. */ led = async (config?: "green" | "red" | "orange"): Promise<number[] | undefined> => { if (this.isConnected()) { const colorMapping: Record<string, number[][]> = { green: [[0x00], [0x01]], red: [[0x01], [0x00]], orange: [[0x01], [0x01]], off: [[0x00], [0x00]], } // Default to "off" color if config is not set or not found in colorMapping const color = typeof config === "string" && colorMapping[config] ? config : "off" const [redValue, greenValue] = colorMapping[color] await this.write("led", "red", new Uint8Array(redValue)) await this.write("led", "green", new Uint8Array(greenValue), 1250) } return undefined } /** * Retrieves manufacturer information from the device. * @returns {Promise<string>} A Promise that resolves with the manufacturer information, */ manufacturer = async (): Promise<string | undefined> => { return await this.read("device", "manufacturer", 250) } /** * Retrieves serial number from the device. * @returns {Promise<string>} A Promise that resolves with the serial number, */ serial = async (): Promise<string | undefined> => { let response: string | undefined = undefined await this.write("uart", "tx", this.commands.GET_SERIAL, 250, (data) => { response = data }) return response } /** * Stops the data stream on the specified device. * @returns {Promise<void>} A promise that resolves when the stream is stopped. */ stop = async (): Promise<void> => { await this.write("uart", "tx", this.commands.STOP_WEIGHT_MEAS, 0) } /** * Starts streaming data from the specified device. * @param {number} [duration=0] - The duration of the stream in milliseconds. If set to 0, stream will continue indefinitely. * @returns {Promise<void>} A promise that resolves when the streaming operation is completed. */ stream = async (duration = 0): Promise<void> => { // Reset download packets this.downloadPackets.length = 0 // Read calibration data if not already available if (!this.calibrationData[0].length) { await this.calibration() } // Start streaming data await this.write("uart", "tx", this.commands.START_WEIGHT_MEAS, duration) // Stop streaming if duration is set if (duration !== 0) { await this.stop() } } /** * Retrieves the entire 320 bytes of non-volatile memory from the device. * * The memory consists of 10 segments, each 32 bytes long. If any segment was previously written, * the corresponding data will appear in the response. Unused portions of the memory are * padded with whitespace. * * @returns {Promise<string>} A Promise that resolves with the 320-byte memory content as a string, */ text = async (): Promise<string | undefined> => { let response: string | undefined = undefined await this.write("uart", "tx", this.commands.GET_TEXT, 250, (data) => { response = data }) return response } }