UNPKG

@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

588 lines 27.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CTS500 = void 0; const device_model_js_1 = require("../device.model.js"); const CTS500_HEADER = 0x05; const CTS500_RESPONSE_FLAG = 0x80; const CTS500_ACK_FRAME_LENGTH = 6; const CTS500_DATA_FRAME_LENGTH = 7; const CTS500_RESPONSE_TIMEOUT_MS = 2000; const CTS500_BAUD_RATE_PARAMS = { 9600: 0x00, 19200: 0x01, 38400: 0x02, 57600: 0x03, 115200: 0x04, }; const CTS500_SAMPLING_RATE_PARAMS = { 10: 0x00, 20: 0x01, 40: 0x02, 80: 0x03, 160: 0x04, 320: 0x05, }; function calculateChecksum(bytes) { let checksum = 0; // CTS frames use a simple additive checksum over every byte before the checksum slot. for (const byte of bytes) { checksum = (checksum + byte) & 0xff; } return checksum; } function buildCommand(opcode, payload = [0x00, 0x00, 0x00]) { const frame = new Uint8Array(CTS500_ACK_FRAME_LENGTH); frame[0] = CTS500_HEADER; frame[1] = opcode; frame[2] = payload[0]; frame[3] = payload[1]; frame[4] = payload[2]; frame[5] = calculateChecksum(frame.subarray(0, frame.length - 1)); return frame; } /** * Represents the CTS500 Climbing Training Scale, marketed as "Jlyscales CTS500". * Supplier: Hunan Jinlian Cloud Information Technology Co., Ltd. * {@link https://www.huaying-scales.com/} * {@link https://www.alibaba.com/product-detail/Mini-Climbing-Training-Scale-CTS500-Aluminum_1601637814595.html} */ class CTS500 extends device_model_js_1.Device { constructor() { super({ filters: [{ name: "CTS-300" }, { name: "CTS500" }], services: [ { name: "Device Information", id: "device", uuid: "0000180a-0000-1000-8000-00805f9b34fb", characteristics: [ { name: "Model Number String", id: "model", uuid: "00002a24-0000-1000-8000-00805f9b34fb", // MY-BT102 https://www.muyusmart.cn/product/my-bt102/ }, // { // 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", // 109a }, { name: "Hardware Revision String", id: "hardware", uuid: "00002a27-0000-1000-8000-00805f9b34fb", //1.0 }, { name: "Software Revision String", id: "software", uuid: "00002a28-0000-1000-8000-00805f9b34fb", // 2.1.3 }, { name: "Manufacturer Name String", id: "manufacturer", uuid: "00002a29-0000-1000-8000-00805f9b34fb", // DX }, ], }, { name: "CTS500 Service", id: "cts500", uuid: "0000ffe0-0000-1000-8000-00805f9b34fb", characteristics: [ { name: "Notify", id: "rx", uuid: "0000ffe1-0000-1000-8000-00805f9b34fb", }, { name: "Write", id: "tx", uuid: "0000ffe2-0000-1000-8000-00805f9b34fb", }, ], }, ], commands: { SET_RANGE: 0x81, // set capacity/range; known presets include 100kg, 200kg, 300kg, 400kg, 500kg, 1T, and 3T SET_DIVISION: 0x82, // set division; known presets include 10g, 20g, 50g, and 0.1kg SET_FIRST_CALIBRATION_WEIGHT: 0x83, // set first calibration reference weight; known presets include 1kg, 5kg, 10kg, 20kg, 50kg, and 100kg SET_SECOND_CALIBRATION_WEIGHT: 0x84, // set second calibration reference weight; known presets shown include 50kg, 100kg, and 200kg POWER_ON_RESET: 0x85, // power-on reset mode; payload 00 disables automatic reset and 01 enables it ZERO_SCALE: buildCommand(0x86), // update the hardware zero point RUN_FIRST_CALIBRATION: buildCommand(0xa1), // run the first calibration step after placing the configured reference weight RUN_SECOND_CALIBRATION: buildCommand(0xa2), // run the second calibration step after placing the configured reference weight GET_FIRMWARE_VERSION: buildCommand(0xa4), // read firmware version over the transparent UART service TARE_SCALE: buildCommand(0xa6), // tare the current load NO_LOAD_CALIBRATION: buildCommand(0xa7), // run the no-load calibration routine with the scale unloaded GET_WEIGHT: buildCommand(0xa9), // read the current weight immediately START_WEIGHT_MEAS: buildCommand(0xaa), // turn on automatic weight uploading STOP_WEIGHT_MEAS: buildCommand(0xab), // turn off automatic weight uploading SET_BAUD_RATE: 0xc0, // set UART baud rate; payload presets are 00=9600, 01=19200, 02=38400, 03=57600, 04=115200 SET_SAMPLING_RATE: 0xc1, // set A/D sampling frequency; payload presets are 00=10Hz, 01=20Hz, 02=40Hz, 03=80Hz, 04=160Hz, 05=320Hz SET_SHUTDOWN_TIME: 0xc3, // set auto-shutdown timer; the shown presets use payload 1E for 30 seconds and 00 to disable GET_BATTERY_VOLTAGE: buildCommand(0xc4), // read battery voltage GET_TEMPERATURE: buildCommand(0xc5), // read temperature SET_UPPER_TEMPERATURE_LIMIT: 0xc6, // set upper temperature limit; the shown example uses payload 1D SET_LOWER_TEMPERATURE_LIMIT: 0xc7, // set lower temperature limit; the shown example uses payload 2A and FF disables the lower limit PEAK_MODE: 0xca, // peak mode; payload 00 turns it off and 01 turns it on SET_MAX_WEIGHT_LIMIT: 0xd1, // set the upper/max weight limit threshold SET_MIN_WEIGHT_LIMIT: 0xd2, // set the lower/min weight limit threshold SET_WEIGHT_ALARM_MODE: 0xd3, // set weight alarm mode; payload 00 cancels, 01 alarms inside the range, and 02 alarms outside the range SET_ALARM_OUTPUT: 0xd4, // enable or disable alarm-frame output; payload 00 turns it off and 01 turns it on }, }); this.bufferedFrames = new Uint8Array(0); this.pendingFrame = undefined; this.requestQueue = Promise.resolve(); this.isStreaming = false; this.commandOpcodes = new Set(); /** * Retrieves battery voltage from the device. * The returned string uses two decimal places, e.g. "3.55". * @returns {Promise<string | undefined>} A Promise that resolves with the battery voltage. */ this.battery = async () => { const command = this.commands.GET_BATTERY_VOLTAGE; const frame = await this.queryFrame(command, (response) => this.isCommandResponse(response, command[1])); if (!frame) { return undefined; } const rawVoltage = (frame[4] << 8) | frame[5]; return (rawVoltage / 100).toFixed(2); }; /** * Retrieves firmware version from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the firmware version. */ this.firmware = async () => { return await this.read("device", "firmware", 250); }; /** * Retrieves hardware version from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the hardware version. */ this.hardware = async () => { return await this.read("device", "hardware", 250); }; /** * Retrieves manufacturer information from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the manufacturer information. */ this.manufacturer = async () => { return await this.read("device", "manufacturer", 250); }; /** * Retrieves model number from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the model number. */ this.model = async () => { return await this.read("device", "model", 250); }; /** * Sets whether the device should reset to zero on power-up. * @param {boolean} enabled - Whether power-on reset should be enabled. * @returns {Promise<void>} A promise that resolves when the command is acknowledged. */ this.powerOnReset = async (enabled) => { await this.expectAck(this.commands.POWER_ON_RESET, [0x00, 0x00, enabled ? 0x01 : 0x00]); }; /** * Enables or disables the device peak mode. * @param {boolean} [enabled=true] - Whether peak mode should be enabled. * @returns {Promise<void>} A promise that resolves when the command is acknowledged. */ this.peakMode = async (enabled = true) => { await this.expectAck(this.commands.PEAK_MODE, [0x00, 0x00, enabled ? 0x01 : 0x00]); }; /** * Configures the device UART baud rate. * @param {CTS500BaudRate} baudRate - Desired baud rate. * @returns {Promise<void>} A promise that resolves when the command is acknowledged. */ this.setBaudRate = async (baudRate) => { await this.applyConfigCommand(this.commands.SET_BAUD_RATE, [ 0x00, 0x00, CTS500_BAUD_RATE_PARAMS[baudRate], ]); }; /** * Configures the device A/D sampling rate. * @param {CTS500SamplingRate} samplingRate - Desired A/D sampling rate in Hz. * @returns {Promise<void>} A promise that resolves when the command is acknowledged. */ this.setSamplingRate = async (samplingRate) => { await this.applyConfigCommand(this.commands.SET_SAMPLING_RATE, [ 0x00, 0x00, CTS500_SAMPLING_RATE_PARAMS[samplingRate], ]); }; /** * Retrieves serial number from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the serial number. */ this.serial = async () => { var _a; const hasSerial = (_a = this.services .find((service) => service.id === "device")) === null || _a === void 0 ? void 0 : _a.characteristics.some((characteristic) => characteristic.id === "serial"); // MY-BT102 variants can omit the serial characteristic entirely, so guard the read instead of letting it throw. if (!hasSerial) { return undefined; } return await this.read("device", "serial", 250); }; /** * Retrieves software version from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the software version. */ this.software = async () => { return await this.read("device", "software", 250); }; /** * Starts automatic weight uploads. * @param {number} [duration=0] - Optional delay before the promise resolves. * @returns {Promise<void>} A promise that resolves once upload mode has been enabled. */ this.stream = async (duration = 0) => { this.resetPacketTracking(); this.isStreaming = true; const command = this.commands.START_WEIGHT_MEAS; await this.queryFrame(command, (frame) => // The device can start auto-uploading before it echoes the start command, so the first weight frame also confirms success. this.isAckFrame(frame, command[1], [command[2], command[3], command[4]]) || this.isWeightFrame(frame)); if (duration > 0) { await new Promise((resolve) => setTimeout(resolve, duration)); } }; /** * Stops automatic weight uploads. * @returns {Promise<void>} A promise that resolves once upload mode has been disabled. */ this.stop = async () => { this.isStreaming = false; const command = this.commands.STOP_WEIGHT_MEAS; await this.queryFrame(command, (frame) => this.isAckFrame(frame, command[1], [command[2], command[3], command[4]])); }; /** * Reads the current temperature from the device. * @returns {Promise<string | undefined>} A Promise that resolves with the temperature in Celsius. */ this.temperature = async () => { const command = this.commands.GET_TEMPERATURE; const frame = await this.queryFrame(command, (response) => this.isCommandResponse(response, command[1])); if (!frame) { return undefined; } const rawTemperature = frame[5]; // Negative temperatures are sent as 0x80 + abs(value) instead of two's complement. const temperature = rawTemperature >= 0x80 ? -(rawTemperature - 0x80) : rawTemperature; return temperature.toString(); }; /** * Uses the device's hardware tare command when connected and falls back to software tare otherwise. * @param {number} [duration=5000] - Software tare duration when the device is not connected. * @returns {boolean} `true` when the tare operation started successfully. */ this.tare = (duration = 5000) => { if (!this.isConnected()) { return super.tare(duration); } this.updateTimestamp(); this.clearTareOffset(); const command = this.commands.TARE_SCALE; void this.queryFrame(command, (frame) => this.isAckFrame(frame, command[1], [command[2], command[3], command[4]])).catch((error) => { console.error(error); }); return true; }; /** * Reads the current weight from the device in kilograms. * @returns {Promise<number | undefined>} A Promise that resolves with the current weight. */ this.weight = async () => { const frame = await this.queryFrame(this.commands.GET_WEIGHT, (response) => this.isWeightFrame(response)); if (!frame) { return undefined; } return (frame[2] * 0x1000000 + frame[3] * 0x10000 + frame[4] * 0x100 + frame[5]) / 100; }; /** * Updates the device hardware zero point. * @returns {Promise<void>} A promise that resolves when the command is acknowledged. */ this.zero = async () => { await this.expectAck(this.commands.ZERO_SCALE); }; /** * Parses UART frames received over the MY-BT102 notify characteristic. * Supports fragmented BLE notifications by buffering until a complete CTS500 frame is available. * * @param {DataView} value - The notification payload from the device. */ this.handleNotifications = (value) => { this.updateTimestamp(); const bytes = new Uint8Array(value.byteLength); for (let index = 0; index < value.byteLength; index++) { bytes[index] = value.getUint8(index); } if (bytes.length === 0) { return; } // BLE notifications can split UART frames arbitrarily, so keep buffering until a full frame validates. const combined = new Uint8Array(this.bufferedFrames.length + bytes.length); combined.set(this.bufferedFrames); combined.set(bytes, this.bufferedFrames.length); this.bufferedFrames = combined; while (this.bufferedFrames.length >= CTS500_ACK_FRAME_LENGTH) { const headerIndex = this.bufferedFrames.indexOf(CTS500_HEADER); if (headerIndex === -1) { this.bufferedFrames = new Uint8Array(0); return; } if (headerIndex > 0) { this.bufferedFrames = this.bufferedFrames.slice(headerIndex); } const frame = this.extractNextFrame(); if (!frame) { return; } this.bufferedFrames = this.bufferedFrames.slice(frame.length); this.handleFrame(frame); } }; /** * Waits for a specific frame pattern after sending a CTS500 command. */ this.queryFrame = async (message, match) => { return await this.enqueueRequest(async () => { const waitForFrame = this.waitForFrame(match); try { await this.write("cts500", "tx", message, 0); return await waitForFrame; } catch (error) { this.clearPendingFrame(error instanceof Error ? error : new Error(String(error))); throw error; } }); }; /** * Sends a command that should be acknowledged with a 6-byte echo frame. */ this.expectAck = async (opcode, payload = [0x00, 0x00, 0x00]) => { await this.queryFrame(buildCommand(opcode, payload), (frame) => this.isAckFrame(frame, opcode, payload)); }; /** * Sends a configuration command that may reply with either a 6-byte echo, a typed response, or no reply after applying. */ this.applyConfigCommand = async (opcode, payload = [0x00, 0x00, 0x00]) => { try { await this.queryFrame(buildCommand(opcode, payload), (frame) => this.isAckFrame(frame, opcode, payload) || this.isCommandResponse(frame, opcode)); } catch (error) { // Some CTS firmwares apply UART/A-D rate changes immediately and do not echo a matching confirmation frame back over BLE. if (error instanceof Error && error.message === "Timed out waiting for CTS500 response") { return; } throw error; } }; /** * Resolves the currently pending frame promise if the incoming frame matches. * @returns {boolean} Whether a pending request consumed the frame. */ this.consumePendingFrame = (frame) => { if (!this.pendingFrame || !this.pendingFrame.match(frame)) { return false; } const { resolve, timeout } = this.pendingFrame; clearTimeout(timeout); this.pendingFrame = undefined; resolve(frame); return true; }; /** * Clears the currently pending frame wait, if any. */ this.clearPendingFrame = (error) => { if (!this.pendingFrame) { return; } const { timeout, reject } = this.pendingFrame; clearTimeout(timeout); this.pendingFrame = undefined; if (error) { reject(error); } }; /** * Extracts the next valid CTS500 frame from the local notification buffer. */ this.extractNextFrame = () => { const secondByte = this.bufferedFrames[1]; // 6-byte command echoes and 7-byte data frames share the same header, so prefer command echoes when byte 1 is a known opcode. if (this.commandOpcodes.has(secondByte) && this.bufferedFrames.length >= CTS500_ACK_FRAME_LENGTH) { const commandCandidate = this.bufferedFrames.slice(0, CTS500_ACK_FRAME_LENGTH); if (this.isValidFrame(commandCandidate)) { return commandCandidate; } } if (this.bufferedFrames.length >= CTS500_DATA_FRAME_LENGTH) { const dataCandidate = this.bufferedFrames.slice(0, CTS500_DATA_FRAME_LENGTH); if (this.isValidFrame(dataCandidate)) { return dataCandidate; } } if (this.bufferedFrames.length >= CTS500_DATA_FRAME_LENGTH) { this.bufferedFrames = this.bufferedFrames.slice(1); } return undefined; }; /** * Routes a validated CTS500 frame to pending requests, callbacks, and stream processing. */ this.handleFrame = (frame) => { const matchedPendingRequest = this.consumePendingFrame(frame); if (this.isWeightFrame(frame)) { // Weight uploads carry a big-endian centi-unit value across bytes 2..5. const weight = (frame[2] * 0x1000000 + frame[3] * 0x10000 + frame[4] * 0x100 + frame[5]) / 100; this.recordWeightMeasurement(weight); this.writeCallback(weight.toFixed(2)); return; } if (this.isCommandResponse(frame, this.commands.GET_BATTERY_VOLTAGE[1])) { const voltage = ((frame[4] << 8) | frame[5]) / 100; this.writeCallback(voltage.toFixed(2)); return; } if (this.isCommandResponse(frame, this.commands.GET_TEMPERATURE[1])) { const rawTemperature = frame[5]; // Negative temperatures are sent as 0x80 + abs(value) instead of two's complement. const temperature = rawTemperature >= 0x80 ? -(rawTemperature - 0x80) : rawTemperature; this.writeCallback(temperature.toString()); return; } if (frame.length === CTS500_ACK_FRAME_LENGTH && !matchedPendingRequest) { this.writeCallback("OK"); return; } if (frame.length === CTS500_DATA_FRAME_LENGTH && !matchedPendingRequest) { this.writeCallback(Array.from(frame) .map((byte) => byte.toString(16).padStart(2, "0").toUpperCase()) .join(" ")); } }; /** * Returns whether a frame is a 6-byte command acknowledgment echo for the given opcode. */ this.isAckFrame = (frame, opcode, payload) => { return (frame.length === CTS500_ACK_FRAME_LENGTH && frame[0] === CTS500_HEADER && frame[1] === opcode && frame[2] === payload[0] && frame[3] === payload[1] && frame[4] === payload[2] && this.isValidFrame(frame)); }; /** * Returns whether a frame is a typed command response (`05 80 <opcode> ... checksum`). */ this.isCommandResponse = (frame, opcode) => { return (frame.length === CTS500_DATA_FRAME_LENGTH && frame[0] === CTS500_HEADER && frame[1] === CTS500_RESPONSE_FLAG && frame[2] === opcode && this.isValidFrame(frame)); }; /** * Returns whether a frame contains a weight measurement payload. */ this.isWeightFrame = (frame) => { return (frame.length === CTS500_DATA_FRAME_LENGTH && frame[0] === CTS500_HEADER && frame[1] !== CTS500_RESPONSE_FLAG && !this.commandOpcodes.has(frame[1]) && this.isValidFrame(frame)); }; /** * Updates rolling statistics and emits a force measurement from a CTS500 weight frame. */ this.recordWeightMeasurement = (receivedData) => { const receivedTime = Date.now(); this.currentSamplesPerPacket = 1; this.recordPacketReceived(); const numericData = receivedData - this.applyTare(receivedData); const currentMassTotal = Math.max(-1000, numericData); this.peak = Math.max(this.peak, numericData); this.min = Math.min(this.min, Math.max(-1000, numericData)); this.sum += currentMassTotal; this.dataPointCount++; this.mean = this.sum / this.dataPointCount; this.downloadPackets.push(this.buildDownloadPacket(currentMassTotal, [Math.round(receivedData * 100)], { timestamp: receivedTime, sampleIndex: this.dataPointCount, })); if (this.isStreaming) { void this.activityCheck(numericData); } this.notifyCallback(this.buildForceMeasurement(currentMassTotal)); }; /** * Validates a CTS500 frame checksum. */ this.isValidFrame = (frame) => { if (frame.length < CTS500_ACK_FRAME_LENGTH || frame[0] !== CTS500_HEADER) { return false; } return calculateChecksum(frame.subarray(0, frame.length - 1)) === frame[frame.length - 1]; }; /** * Registers a pending frame matcher with a timeout. */ this.waitForFrame = (match, timeoutMs = CTS500_RESPONSE_TIMEOUT_MS) => { // CTS uses one transparent UART channel for both commands and telemetry, so only one response wait can be active at a time. if (this.pendingFrame) { throw new Error("CTS500 already has a pending response request"); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { if (!this.pendingFrame) { return; } this.pendingFrame = undefined; reject(new Error("Timed out waiting for CTS500 response")); }, timeoutMs); this.pendingFrame = { match, reject, resolve, timeout, }; }); }; /** * Serializes CTS500 command/response operations so query-style methods can be called in parallel by consumers. */ this.enqueueRequest = async (request) => { const run = this.requestQueue.then(request, request); this.requestQueue = run.then(() => undefined, () => undefined); return await run; }; for (const command of Object.values(this.commands)) { // Command echoes identify themselves by opcode byte 1 whether the command is stored as a raw opcode or a full frame. if (typeof command === "number") { this.commandOpcodes.add(command); } else if (command instanceof Uint8Array && command.length >= 2) { this.commandOpcodes.add(command[1]); } } } } exports.CTS500 = CTS500; //# sourceMappingURL=cts500.model.js.map