@hangtime/grip-connect
Version:
Griptonite Motherboard, Tindeq Progressor, PitchSix Force Board, WHC-06, Entralpi, Climbro, mySmartBoard: Bluetooth API Force-Sensing strength analysis for climbers
399 lines • 18.4 kB
JavaScript
import { Device } from "../device.model.js";
/**
* Represents a Griptonite Motherboard device.
* {@link https://griptonite.io}
*/
export class Motherboard extends Device {
/**
* Length of the packet received from the device.
* @type {number}
* @static
* @readonly
* @constant
*/
static packetLength = 32;
/**
* Number of samples contained in the data packet.
* @type {number}
* @static
* @readonly
* @constant
*/
static samplesNumber = 3;
/**
* Buffer to store received data from the device.
* @type {number[]}
* @private
*/
receiveBuffer = [];
/**
* Calibration data for each sensor of the device.
* @type {number[][][]}
* @private
*/
calibrationData = [[], [], [], []];
/** Per-zone peak and running sum for left/center/right (used for distribution stats). */
leftPeak = Number.NEGATIVE_INFINITY;
leftSum = 0;
centerPeak = Number.NEGATIVE_INFINITY;
centerSum = 0;
rightPeak = Number.NEGATIVE_INFINITY;
rightSum = 0;
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.
*/
applyCalibration = (sample, calibration) => {
// Extract the calibrated value for the zero point
const zeroCalibration = 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 = calibration[i - 1][2];
const calibrationEnd = 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 () => {
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 () => {
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 () => {
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.
*/
handleNotifications = (value) => {
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;
while ((idx = this.receiveBuffer.indexOf(10)) >= 0) {
const line = 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 = new TextDecoder("utf-8");
const receivedData = decoder.decode(new Uint8Array(line));
const receivedTime = Date.now();
// Check if the line is entirely hex characters
const isAllHex = /^[0-9A-Fa-f]+$/g.test(receivedData);
// Handle streaming packet
if (isAllHex && receivedData.length === Motherboard.packetLength) {
this.currentSamplesPerPacket = Motherboard.samplesNumber;
this.recordPacketReceived();
// Base-16 decode the string: convert hex pairs to byte values
const bytes = 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 sampleIndex = new DataView(new Uint8Array(bytes).buffer).getUint16(0, true);
const battRaw = new DataView(new Uint8Array(bytes).buffer).getUint16(2, true);
const packet = {
timestamp: receivedTime,
sampleIndex,
battRaw,
samples: [],
forces: [],
};
const dataView = new DataView(new Uint8Array(bytes).buffer);
for (let i = 0; i < Motherboard.samplesNumber; i++) {
const sampleStart = 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.forces[i] = this.applyCalibration(packet.samples[i], this.calibrationData[i]);
}
// invert center and right values
packet.forces[1] *= -1;
packet.forces[2] *= -1;
let left = packet.forces[0];
let center = packet.forces[1];
let right = packet.forces[2];
// Tare correction
left -= this.applyTare(left);
center -= this.applyTare(center);
right -= this.applyTare(right);
const totalCurrent = Math.max(-1000, left + center + right);
const leftClamped = Math.max(-1000, left);
const centerClamped = Math.max(-1000, center);
const rightClamped = Math.max(-1000, right);
// Update session stats before building packet
this.peak = Math.max(this.peak, totalCurrent);
this.min = Math.min(this.min, totalCurrent);
this.sum += totalCurrent;
this.dataPointCount++;
this.mean = this.sum / this.dataPointCount;
// Per-zone peak and sum for distribution
this.leftPeak = Math.max(this.leftPeak, leftClamped);
this.leftSum += leftClamped;
this.centerPeak = Math.max(this.centerPeak, centerClamped);
this.centerSum += centerClamped;
this.rightPeak = Math.max(this.rightPeak, rightClamped);
this.rightSum += rightClamped;
// Add data to downloadable Array (distribution = per-zone measurements)
this.downloadPackets.push(this.buildDownloadPacket(totalCurrent, [...packet.samples], {
timestamp: packet.timestamp,
battRaw: packet.battRaw,
sampleIndex: packet.sampleIndex,
distribution: {
left: this.buildZoneMeasurement(leftClamped, this.leftPeak, this.leftSum / this.dataPointCount),
center: this.buildZoneMeasurement(centerClamped, this.centerPeak, this.centerSum / this.dataPointCount),
right: this.buildZoneMeasurement(rightClamped, this.rightPeak, this.rightSum / this.dataPointCount),
},
}));
// Check if device is being used
this.activityCheck(center);
// Notify with weight data (distribution zones have proper peak/mean per zone)
this.notifyCallback(this.buildForceMeasurement(totalCurrent, {
left: this.buildZoneMeasurement(leftClamped, this.leftPeak, this.leftSum / this.dataPointCount),
center: this.buildZoneMeasurement(centerClamped, this.centerPeak, this.centerSum / this.dataPointCount),
right: this.buildZoneMeasurement(rightClamped, this.rightPeak, this.rightSum / this.dataPointCount),
}));
}
else if (this.writeLast === this.commands.GET_CALIBRATION) {
// check data integrity
if ((receivedData.match(/,/g) || []).length === 3) {
const parts = receivedData.split(",");
const numericParts = parts.map((x) => parseFloat(x));
this.calibrationData[numericParts[0]].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 () => {
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) => {
if (this.isConnected()) {
const colorMapping = {
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 () => {
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 () => {
let response = 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 () => {
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) => {
this.resetPacketTracking();
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 () => {
let response = undefined;
await this.write("uart", "tx", this.commands.GET_TEXT, 250, (data) => {
response = data;
});
return response;
};
}
//# sourceMappingURL=motherboard.model.js.map