@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
703 lines (620 loc) • 24.9 kB
text/typescript
import { Device } from "../device.model.js"
import type { CTS500BaudRate, CTS500SamplingRate, ICTS500 } from "../../interfaces/device/cts500.interface.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: Record<CTS500BaudRate, number> = {
9600: 0x00,
19200: 0x01,
38400: 0x02,
57600: 0x03,
115200: 0x04,
}
const CTS500_SAMPLING_RATE_PARAMS: Record<CTS500SamplingRate, number> = {
10: 0x00,
20: 0x01,
40: 0x02,
80: 0x03,
160: 0x04,
320: 0x05,
}
function calculateChecksum(bytes: Uint8Array): number {
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: number, payload: readonly [number, number, number] = [0x00, 0x00, 0x00]): Uint8Array {
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
}
interface PendingFrame {
match(frame: Uint8Array): boolean
reject(error: Error): void
resolve(frame: Uint8Array): void
timeout: ReturnType<typeof setTimeout>
}
/**
* 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}
*/
export class CTS500 extends Device implements ICTS500 {
private bufferedFrames = new Uint8Array(0)
private pendingFrame: PendingFrame | undefined = undefined
private requestQueue: Promise<void> = Promise.resolve()
private isStreaming = false
private commandOpcodes = new Set<number>()
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
},
})
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])
}
}
}
/**
* 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.
*/
battery = async (): Promise<string | undefined> => {
const command = this.commands.GET_BATTERY_VOLTAGE as Uint8Array
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.
*/
firmware = async (): Promise<string | undefined> => {
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.
*/
hardware = async (): Promise<string | undefined> => {
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.
*/
manufacturer = async (): Promise<string | undefined> => {
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.
*/
model = async (): Promise<string | undefined> => {
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.
*/
powerOnReset = async (enabled: boolean): Promise<void> => {
await this.expectAck(this.commands.POWER_ON_RESET as number, [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.
*/
peakMode = async (enabled = true): Promise<void> => {
await this.expectAck(this.commands.PEAK_MODE as number, [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.
*/
setBaudRate = async (baudRate: CTS500BaudRate): Promise<void> => {
await this.applyConfigCommand(this.commands.SET_BAUD_RATE as number, [
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.
*/
setSamplingRate = async (samplingRate: CTS500SamplingRate): Promise<void> => {
await this.applyConfigCommand(this.commands.SET_SAMPLING_RATE as number, [
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.
*/
serial = async (): Promise<string | undefined> => {
const hasSerial = this.services
.find((service) => service.id === "device")
?.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.
*/
software = async (): Promise<string | undefined> => {
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.
*/
stream = async (duration = 0): Promise<void> => {
this.resetPacketTracking()
this.isStreaming = true
const command = this.commands.START_WEIGHT_MEAS as Uint8Array
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.
*/
stop = async (): Promise<void> => {
this.isStreaming = false
const command = this.commands.STOP_WEIGHT_MEAS as Uint8Array
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.
*/
temperature = async (): Promise<string | undefined> => {
const command = this.commands.GET_TEMPERATURE as Uint8Array
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.
*/
override tare = (duration = 5000): boolean => {
if (!this.isConnected()) {
return super.tare(duration)
}
this.updateTimestamp()
this.clearTareOffset()
const command = this.commands.TARE_SCALE as Uint8Array
void this.queryFrame(command, (frame) =>
this.isAckFrame(frame, command[1], [command[2], command[3], command[4]]),
).catch((error: 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.
*/
weight = async (): Promise<number | undefined> => {
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.
*/
zero = async (): Promise<void> => {
await this.expectAck(this.commands.ZERO_SCALE as number)
}
/**
* 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.
*/
override handleNotifications = (value: DataView): void => {
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.
*/
private queryFrame = async (
message: string | Uint8Array | undefined,
match: (frame: Uint8Array) => boolean,
): Promise<Uint8Array | undefined> => {
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.
*/
private expectAck = async (
opcode: number,
payload: readonly [number, number, number] = [0x00, 0x00, 0x00],
): Promise<void> => {
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.
*/
private applyConfigCommand = async (
opcode: number,
payload: readonly [number, number, number] = [0x00, 0x00, 0x00],
): Promise<void> => {
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.
*/
private consumePendingFrame = (frame: Uint8Array): boolean => {
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.
*/
private clearPendingFrame = (error?: Error): void => {
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.
*/
private extractNextFrame = (): Uint8Array | undefined => {
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.
*/
private handleFrame = (frame: Uint8Array): void => {
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 as Uint8Array)[1])) {
const voltage = ((frame[4] << 8) | frame[5]) / 100
this.writeCallback(voltage.toFixed(2))
return
}
if (this.isCommandResponse(frame, (this.commands.GET_TEMPERATURE as Uint8Array)[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.
*/
private isAckFrame = (frame: Uint8Array, opcode: number, payload: readonly [number, number, number]): boolean => {
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`).
*/
private isCommandResponse = (frame: Uint8Array, opcode: number): boolean => {
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.
*/
private isWeightFrame = (frame: Uint8Array): boolean => {
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.
*/
private recordWeightMeasurement = (receivedData: number): void => {
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.
*/
private isValidFrame = (frame: Uint8Array): boolean => {
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.
*/
private waitForFrame = (
match: (frame: Uint8Array) => boolean,
timeoutMs = CTS500_RESPONSE_TIMEOUT_MS,
): Promise<Uint8Array> => {
// 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<Uint8Array>((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.
*/
private enqueueRequest = async <T>(request: () => Promise<T>): Promise<T> => {
const run = this.requestQueue.then(request, request)
this.requestQueue = run.then(
() => undefined,
() => undefined,
)
return await run
}
}