@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
JavaScript
"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