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

459 lines 21.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Progressor = void 0; const device_model_js_1 = require("../device.model.js"); /** * Progressor responses */ var ProgressorResponses; (function (ProgressorResponses) { /** * Response received after sending a command to the device. * This could include acknowledgment or specific data related to the command sent. */ ProgressorResponses[ProgressorResponses["RESPONSE_COMMAND"] = 0] = "RESPONSE_COMMAND"; /** * Data representing a weight measurement from the device. * Typically used for tracking load or force applied. */ ProgressorResponses[ProgressorResponses["RESPONSE_WEIGHT_MEASUREMENT"] = 1] = "RESPONSE_WEIGHT_MEASUREMENT"; /** * Peak rate of force development (RFD) measurement. * This measures how quickly the force is applied over time. */ ProgressorResponses[ProgressorResponses["RESPONSE_RFD_PEAK"] = 2] = "RESPONSE_RFD_PEAK"; /** * Series of peak rate of force development (RFD) measurements. * This could be used for analyzing force trends over multiple data points. */ ProgressorResponses[ProgressorResponses["RESPONSE_RFD_PEAK_SERIES"] = 3] = "RESPONSE_RFD_PEAK_SERIES"; /** * Low battery warning from the device. * Indicates that the battery level is below a critical threshold. */ ProgressorResponses[ProgressorResponses["RESPONSE_LOW_POWER_WARNING"] = 4] = "RESPONSE_LOW_POWER_WARNING"; })(ProgressorResponses || (ProgressorResponses = {})); /** * Represents a Tindeq Progressor device. * {@link https://tindeq.com} */ /** One second in microseconds (device timestamp unit). Used for Hz = samples in last 1s. */ const ONE_SECOND_US = 1000000; /** * Format bytes as hex string. * @param payload - Bytes to format * @param separator - String between bytes (default " ") */ function toHex(payload, separator = " ") { return Array.from(payload) .map((b) => b.toString(16).padStart(2, "0")) .join(separator); } /** * Parse ProgressorId response: u64 little-endian, device may omit trailing zero bytes. * Format as hex string MSB-first to match the official app. */ function parseProgressorIdPayload(payload) { if (payload.length === 0) return ""; const reversed = Uint8Array.from(payload); reversed.reverse(); return toHex(reversed, "").toUpperCase(); } /** * Parse calibration block: 3× float32 LE. * value = raw * slope + intercept + trim. */ function parseCalibrationCurvePayload(payload) { const hex = toHex(payload); if (payload.length !== 12) return hex; const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); const slope = view.getFloat32(0, true); const intercept = view.getFloat32(4, true); const trim = view.getFloat32(8, true); const effectiveOffset = intercept + trim; const formatSignedFloat = (value) => { const formatted = formatCalibrationFloat(Math.abs(value)); return value < 0 ? ` - ${formatted}` : ` + ${formatted}`; }; return `${hex} — slope: ${formatCalibrationFloat(slope)} | intercept: ${formatCalibrationFloat(intercept)} | trim: ${formatCalibrationFloat(trim)} | effective offset: ${formatCalibrationFloat(effectiveOffset)} | formula: raw * ${formatCalibrationFloat(slope)}${formatSignedFloat(intercept)}${formatSignedFloat(trim)}`; } /** * Format floating-point values for calibration-table display. */ function formatCalibrationFloat(value) { if (!Number.isFinite(value)) return String(value); const abs = Math.abs(value); return abs !== 0 && (abs >= 1000000 || abs < 0.0001) ? value.toExponential(6) : value.toFixed(6); } /** * Parse one calibration table record: [u32 lower, u32 upper, f32 slope, f32 intercept]. */ function parseCalibrationTableRecordPayload(payload, index) { if (payload.length !== 16) return `${String(index).padStart(2, "0")}: ${toHex(payload)}`; const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); const lowerRaw = view.getUint32(0, true); const upperRaw = view.getUint32(4, true); const slope = view.getFloat32(8, true); const intercept = view.getFloat32(12, true); const hex = toHex(payload); return `${String(index).padStart(2, "0")}: ${hex} | raw ${lowerRaw.toLocaleString()}..${upperRaw.toLocaleString()} | slope ${formatCalibrationFloat(slope)} | intercept ${formatCalibrationFloat(intercept)}`; } class Progressor extends device_model_js_1.Device { constructor() { super({ filters: [{ namePrefix: "Progressor" }], services: [ { name: "Progressor Service", id: "progressor", uuid: "7e4e1701-1ea6-40c9-9dcc-13d34ffead57", characteristics: [ { name: "Notify", id: "rx", uuid: "7e4e1702-1ea6-40c9-9dcc-13d34ffead57", }, { name: "Write", id: "tx", uuid: "7e4e1703-1ea6-40c9-9dcc-13d34ffead57", }, ], }, { name: "Nordic Device Firmware Update (DFU) Service", id: "dfu", uuid: "0000fe59-0000-1000-8000-00805f9b34fb", characteristics: [ { name: "Buttonless DFU", id: "dfu", uuid: "8ec90003-f315-4f60-9fb8-838830daea50", }, ], }, ], // Tindeq API: opcode = single byte (ASCII char code = decimal 100–114 v2 firmware: 115-118) commands: { TARE_SCALE: "d", // 100 (0x64) START_WEIGHT_MEAS: "e", // 101 (0x65) STOP_WEIGHT_MEAS: "f", // 102 (0x66) START_PEAK_RFD_MEAS: "g", // 103 (0x67) START_PEAK_RFD_MEAS_SERIES: "h", // 104 (0x68) ADD_CALIBRATION_POINT: "i", // 105 (0x69) SAVE_CALIBRATION: "j", // 106 (0x6a) GET_FIRMWARE_VERSION: "k", // 107 (0x6b) GET_ERROR_INFORMATION: "l", // 108 (0x6c) CLR_ERROR_INFORMATION: "m", // 109 (0x6d) SLEEP: "n", // 110 (0x6e) GET_BATTERY_VOLTAGE: "o", // 111 (0x6f) GET_PROGRESSOR_ID: "p", // 112 (0x70) SET_CALIBRATION: "q", // 113 (0x71) GET_CALIBRATION: "r", // 114 (0x72) // V2 FIRMWARE ONLY COMMANDS // ADD_CALIBRATION_TABLE_POINT: "s", // 115 (0x73) GET_CALIBRATION_TABLE: "t", // 116 (0x74) REBOOT: "u", // 117 (0x75) // CLR_CALIBRATION_TABLE: "v", // 118 (0x76) }, }); /** Device timestamps (µs) of recent samples (samples in last 1s device time). */ this.recentSampleTimestamps = []; /** 1-based index for multi-packet calibration-table export responses. */ this.calibrationTableRecordIndex = 0; /** * Retrieves battery or voltage information from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the battery or voltage information, */ this.battery = async () => { let response = undefined; await this.write("progressor", "tx", this.commands.GET_BATTERY_VOLTAGE, 250, (data) => { response = data; }); return response; }; /** * Retrieves firmware version from the device. * @returns {Promise<string>} A Promise that resolves with the firmware version, */ this.firmware = async () => { let response = undefined; await this.write("progressor", "tx", this.commands.GET_FIRMWARE_VERSION, 250, (data) => { response = data; }); return response; }; /** * Retrieves the Progressor ID from the device. * @returns {Promise<string>} A Promise that resolves with the raw response (hex of payload). */ this.progressorId = async () => { let response = undefined; await this.write("progressor", "tx", this.commands.GET_PROGRESSOR_ID, 250, (data) => { response = data; }); return response; }; /** * Retrieves the linear calibration block from the device. * Returns raw hex plus decoded slope/intercept/trim coefficients. */ this.calibration = async () => { let response = undefined; await this.write("progressor", "tx", this.commands.GET_CALIBRATION, 250, (data) => { response = data; }); return response; }; /** * Retrieves the hidden 15-entry piecewise calibration table. * Each response packet contains one 16-byte record. * @returns {Promise<string | undefined>} Newline-separated decoded records. */ this.calibrationTable = async () => { const responses = []; this.calibrationTableRecordIndex = 0; await this.write("progressor", "tx", this.commands.GET_CALIBRATION_TABLE, 1000, (data) => { responses.push(data); }); return responses.length > 0 ? responses.join("\n") : undefined; }; /** * Computes calibration curve from stored points and saves to flash. * Requires addCalibrationPoint() for zero and reference. Normal flow: i → i → j. * @returns {Promise<void>} A Promise that resolves when the command is sent. */ this.saveCalibration = async () => { await this.write("progressor", "tx", this.commands.SAVE_CALIBRATION, 0); }; /** * Write calibration block directly (raw overwrite). * * Payload layout (14 bytes): * - [0] opcode ('q') * - [1] reserved (ignored by firmware) * - [2..13] 12-byte calibration block (3× float32 LE: slope, intercept, trim) * * Notes: * - This command does not compute anything; it overwrites stored calibration data. * - Sending only the opcode (no 12-byte calibration block) is not a supported "reset" mode. * * @param curve - Raw 12-byte calibration block (3× float32 LE: slope, intercept, trim) (required). * @returns Promise that resolves when the command is sent. */ this.setCalibration = async (curve) => { if (curve.length !== 12) throw new Error("Curve must be 12 bytes"); const opcode = this.commands.SET_CALIBRATION.charCodeAt(0); const payload = new Uint8Array(14); payload[0] = opcode; payload[1] = 0; // reserved/ignored payload.set(curve, 2); await this.write("progressor", "tx", payload, 0); }; /** * Captures a calibration point from the *current live measurement*. * * Command: 0x69 ('i') written to the control characteristic. * * The firmware does **not** parse a float payload for this command. It simply snapshots the * current raw ADC/force reading and stores it as the next calibration point (typically * used as the zero point and the reference point for two-point calibration). * * Typical two-point calibration flow: * 1) Ensure the device is stable with **no load** attached → send addCalibrationPoint() (zero point) * 2) Attach a **known weight** and wait until stable → send addCalibrationPoint() (reference point) * 3) Call saveCalibration() ('j') to compute + persist the curve * * Notes: * - Order usually doesn’t matter, but capturing the zero point first is common practice. * - Any extra payload bytes are ignored by the firmware for this command. * * @returns {Promise<void>} Resolves when the command is sent. */ this.addCalibrationPoint = async () => { const payload = new Uint8Array([this.commands.ADD_CALIBRATION_POINT.charCodeAt(0)]); // 0x69 await this.write("progressor", "tx", payload, 0); }; /** True if tare() uses device hardware tare rather than software averaging. */ this.usesHardwareTare = true; /** * Puts the device to sleep / shutdown. * @returns {Promise<void>} A Promise that resolves when the command is sent. */ this.sleep = async () => { const cmd = this.commands.SLEEP; await this.write("progressor", "tx", typeof cmd === "string" ? cmd : String(cmd), 0); }; /** * Reboots the device immediately. * @returns {Promise<void>} A Promise that resolves when the command is sent. */ this.reboot = async () => { const opcode = this.commands.REBOOT.charCodeAt(0); // Send byte 1 to trigger the reboot. await this.write("progressor", "tx", new Uint8Array([opcode, 0, 1]), 0); }; /** * Retrieves error information from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the error info text. */ this.errorInfo = async () => { let response = undefined; await this.write("progressor", "tx", this.commands.GET_ERROR_INFORMATION, 250, (data) => { response = data; }); return response; }; /** * Clears error information on the device. * @returns {Promise<void>} A Promise that resolves when the command is sent. */ this.clearErrorInfo = async () => { await this.write("progressor", "tx", this.commands.CLR_ERROR_INFORMATION, 0); }; /** * Handles data received from the device, processes weight measurements, * and updates mass data including maximum and average values. * It also handles command responses for retrieving device information. * * @param {DataView} value - The notification event. */ this.handleNotifications = (value) => { var _a; if (!(value === null || value === void 0 ? void 0 : value.buffer)) return; // Update timestamp this.updateTimestamp(); const receivedTime = Date.now(); // Read the first byte of the buffer to determine the kind of message const kind = value.getUint8(0); const payloadLength = value.getUint8(1); const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); const payload = bytes.slice(2, 2 + payloadLength); // Check if the message is a weight measurement if (kind === ProgressorResponses.RESPONSE_WEIGHT_MEASUREMENT) { if (payloadLength % 8 !== 0) return; const samplesPerPacket = payloadLength / 8; this.currentSamplesPerPacket = samplesPerPacket; this.recordPacketReceived(); for (let i = 0; i < samplesPerPacket; i++) { const offset = 2 + i * 8; const weight = value.getFloat32(offset, true); const timestampUs = value.getUint32(offset + 4, true); if (Number.isNaN(weight)) continue; const numericData = weight - this.applyTare(weight); const currentMassTotal = Math.max(-1000, Number(numericData)); // Update session stats before building packet this.peak = Math.max(this.peak, Number(numericData)); this.min = Math.min(this.min, Math.max(-1000, Number(numericData))); this.sum += currentMassTotal; this.dataPointCount++; this.mean = this.sum / this.dataPointCount; this.downloadPackets.push(this.buildDownloadPacket(currentMassTotal, [weight], { timestamp: receivedTime, sampleIndex: timestampUs, })); this.activityCheck(numericData); // Hz from device timestamps: keep only samples in last 1s this.recentSampleTimestamps.push(timestampUs); const latestUs = (_a = this.recentSampleTimestamps[this.recentSampleTimestamps.length - 1]) !== null && _a !== void 0 ? _a : 0; this.recentSampleTimestamps = this.recentSampleTimestamps.filter((ts) => latestUs - ts <= ONE_SECOND_US); const samplingRateHz = this.recentSampleTimestamps.length; const payload = this.buildForceMeasurement(currentMassTotal); if (payload.performance) payload.performance.samplingRateHz = samplingRateHz; this.notifyCallback(payload); } } // Command response else if (kind === ProgressorResponses.RESPONSE_COMMAND) { if (!this.writeLast) return; let output; if (this.writeLast === this.commands.GET_BATTERY_VOLTAGE) { output = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0, true).toString(); } else if (this.writeLast === this.commands.GET_FIRMWARE_VERSION) { output = new TextDecoder().decode(payload); } else if (this.writeLast === this.commands.GET_ERROR_INFORMATION) { output = new TextDecoder().decode(payload); } else if (this.writeLast === this.commands.GET_PROGRESSOR_ID) { output = parseProgressorIdPayload(payload); } else if (this.writeLast === this.commands.GET_CALIBRATION) { output = parseCalibrationCurvePayload(payload); } else if (this.writeLast === this.commands.GET_CALIBRATION_TABLE) { this.calibrationTableRecordIndex += 1; output = parseCalibrationTableRecordPayload(payload, this.calibrationTableRecordIndex); } else { // Unknown command response: return raw hex output = toHex(payload); } this.writeCallback(output); } // RFD peak response else if (kind === ProgressorResponses.RESPONSE_RFD_PEAK) { console.warn("⚠️ RFD peak is currently unsupported."); } // RFD peak series response else if (kind === ProgressorResponses.RESPONSE_RFD_PEAK_SERIES) { console.warn("⚠️ RFD peak series is currently unsupported."); } // Low power warning response else if (kind === ProgressorResponses.RESPONSE_LOW_POWER_WARNING) { console.warn("⚠️ Low power detected. Please consider connecting to a power source."); } else { throw new Error(`Unknown message kind detected: ${kind}`); } }; /** * Stops the data stream on the specified device. * @returns {Promise<void>} A promise that resolves when the stream is stopped. */ this.stop = async () => { await this.write("progressor", "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. */ this.stream = async (duration = 0) => { // Reset download packets and session stats for fresh measurement this.downloadPackets.length = 0; this.peak = Number.NEGATIVE_INFINITY; this.mean = 0; this.sum = 0; this.dataPointCount = 0; this.min = Number.POSITIVE_INFINITY; this.resetPacketTracking(); this.recentSampleTimestamps = []; // Start streaming data await this.write("progressor", "tx", this.commands.START_WEIGHT_MEAS, duration); // Stop streaming if duration is set if (duration !== 0) { await this.stop(); } }; } tare(duration = 5000) { void duration; // Accepted for API compatibility; hardware tare ignores it this.clearTareOffset(); void this.write("progressor", "tx", this.commands.TARE_SCALE, 0); return true; } } exports.Progressor = Progressor; //# sourceMappingURL=progressor.model.js.map