@hangtime/grip-connect
Version:
Griptonite Motherboard, Tindeq Progressor, PitchSix Force Board, WHC-06, Entralpi, Climbro, mySmartBoard: Bluetooth API Force-Sensing strength analysis for climbers
323 lines (322 loc) • 13.5 kB
JavaScript
import { Device } from "../device.model.js";
/**
* For API level 2 and API level 3.
* The first byte in the data is dependent on where the packet is in the message as a whole.
* More details: https://github.com/1-max-1/fake_kilter_board
*/
export var KilterBoardPacket;
(function (KilterBoardPacket) {
/** If this packet is in the middle, the byte gets set to 77 (M). */
KilterBoardPacket[KilterBoardPacket["V2_MIDDLE"] = 77] = "V2_MIDDLE";
/** If this packet is the first packet in the message, then this byte gets set to 78 (N). */
KilterBoardPacket[KilterBoardPacket["V2_FIRST"] = 78] = "V2_FIRST";
/** If this is the last packet in the message, this byte gets set to 79 (0). */
KilterBoardPacket[KilterBoardPacket["V2_LAST"] = 79] = "V2_LAST";
/** If this packet is the only packet in the message, the byte gets set to 80 (P). Note that this takes priority over the other conditions. */
KilterBoardPacket[KilterBoardPacket["V2_ONLY"] = 80] = "V2_ONLY";
/** If this packet is in the middle, the byte gets set to 81 (Q). */
KilterBoardPacket[KilterBoardPacket["V3_MIDDLE"] = 81] = "V3_MIDDLE";
/** If this packet is the first packet in the message, then this byte gets set to 82 (R). */
KilterBoardPacket[KilterBoardPacket["V3_FIRST"] = 82] = "V3_FIRST";
/** If this is the last packet in the message, this byte gets set to 83 (S). */
KilterBoardPacket[KilterBoardPacket["V3_LAST"] = 83] = "V3_LAST";
/** If this packet is the only packet in the message, the byte gets set to 84 (T). Note that this takes priority over the other conditions. */
KilterBoardPacket[KilterBoardPacket["V3_ONLY"] = 84] = "V3_ONLY";
})(KilterBoardPacket || (KilterBoardPacket = {}));
/**
* Extracted from placement_roles database table.
*/
export const KilterBoardPlacementRoles = [
{
id: 12,
product_id: 1,
position: 1,
name: "start",
full_name: "Start",
led_color: "00FF00",
screen_color: "00DD00",
},
{
id: 13,
product_id: 1,
position: 2,
name: "middle",
full_name: "Middle",
led_color: "00FFFF",
screen_color: "00FFFF",
},
{
id: 14,
product_id: 1,
position: 3,
name: "finish",
full_name: "Finish",
led_color: "FF00FF",
screen_color: "FF00FF",
},
{
id: 15,
product_id: 1,
position: 4,
name: "foot",
full_name: "Foot Only",
led_color: "FFB600",
screen_color: "FFA500",
},
];
/**
* Represents a Aurora Climbing device.
* Kilter Board, Tension Board, Decoy Board, Touchstone Board, Grasshopper Board, Aurora Board, So iLL Board
* {@link https://auroraclimbing.com}
*/
export class KilterBoard extends Device {
/**
* UUID for the Aurora Climbing Advertising service.
* This constant is used to identify the specific Bluetooth service for Kilter Boards.
* @type {string}
* @static
* @readonly
* @constant
*/
static AuroraUUID = "4488b571-7806-4df6-bcff-a2897e4953ff";
/**
* Maximum length of the message body for byte wrapping.
* This value defines the limit for the size of messages that can be sent or received
* to ensure proper byte wrapping in communication.
* @type {number}
* @private
* @readonly
* @constant
*/
static messageBodyMaxLength = 255;
/**
* Maximum length of the Bluetooth message chunk.
* This value sets the upper limit for the size of individual Bluetooth messages
* sent to and from the device to comply with Bluetooth protocol constraints.
* @type {number}
* @private
* @readonly
* @constant
*/
static maxBluetoothMessageSize = 20;
constructor() {
super({
filters: [
{
services: [KilterBoard.AuroraUUID],
},
],
services: [
{
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",
// },
],
},
],
});
}
/**
* Calculates the checksum for a byte array by summing up all bytes ot hre packet in a single-byte variable.
* @param data - The array of bytes to calculate the checksum for.
* @returns {number} The calculated checksum value.
*/
checksum(data) {
let i = 0;
for (const value of data) {
i = (i + value) & 255;
}
return ~i & 255;
}
/**
* Wraps a byte array with header and footer bytes for transmission.
* @param data - The array of bytes to wrap.
* @returns {number[]} The wrapped byte array.
*/
wrapBytes(data) {
if (data.length > KilterBoard.messageBodyMaxLength) {
return [];
}
/**
- 0x1
- len(packets)
- checksum(packets)
- 0x2
- *packets
- 0x3
First byte is always 1, the second is a number of packets, then checksum, then 2, packets themselves, and finally 3.
*/
return [1, data.length, this.checksum(data), 2, ...data, 3];
}
/**
* Encodes a position into a byte array.
* The lowest 8 bits of the position get put in the first byte of the group.
* The highest 8 bits of the position get put in the second byte of the group.
* @param position - The position to encode.
* @returns {number[]} The encoded byte array representing the position.
*/
encodePosition(position) {
const position1 = position & 255;
const position2 = (position & 65280) >> 8;
return [position1, position2];
}
/**
* Encodes a color string into a numeric representation.
* The rgb color, 3 bits for the R and G components, 2 bits for the B component, with the 3 R bits occupying the high end of the byte and the 2 B bits in the low end (hence 3 G bits in the middle).
* Format: 0bRRRGGGBB where RRR is 3 bits for red, GGG is 3 bits for green, BB is 2 bits for blue.
* @param color - The color string in hexadecimal format (e.g., 'FFFFFF').
* @returns The encoded /compressed color value.
*/
encodeColor(color) {
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
// Integer division: R and G divided by 32, B divided by 64
// Then pack into 0bRRRGGGBB format
const rBits = Math.floor(r / 32); // 0-7 (3 bits)
const gBits = Math.floor(g / 32); // 0-7 (3 bits)
const bBits = Math.floor(b / 64); // 0-3 (2 bits)
// Pack: RRR in bits 7-5, GGG in bits 4-2, BB in bits 1-0
return (rBits << 5) | (gBits << 2) | bBits;
}
/**
* Encodes a placement (requires a 16-bit position and a 24-bit rgb color. ) into a byte array.
* @param position - The position to encode.
* @param ledColor - The color of the LED in hexadecimal format (e.g., 'FFFFFF').
* @returns The encoded byte array representing the placement.
*/
encodePlacement(position, ledColor) {
return [...this.encodePosition(position), this.encodeColor(ledColor)];
}
/**
* Prepares byte arrays for transmission based on a list of climb placements.
* @param {{ position: number; role_id?: number; color?: string }[]} climbPlacementList - The list of climb placements containing position and either role ID or color.
* @returns {number[]} The final byte array ready for transmission.
*/
prepBytesV3(climbPlacementList) {
const resultArray = [];
let tempArray = [KilterBoardPacket.V3_MIDDLE];
// Filter out any invalid placements and clean up objects before processing
const validPlacements = climbPlacementList
.filter((p) => {
// Explicitly check for color
const hasColor = p.color != null && typeof p.color === "string" && p.color.trim() !== "";
// Explicitly check for role_id - must be a number, not undefined or null
const hasRoleId = typeof p.role_id === "number" && !isNaN(p.role_id);
return hasColor || hasRoleId;
})
.map((p) => {
// Create clean objects without undefined properties
if (p.color != null && typeof p.color === "string" && p.color.trim() !== "") {
return { position: p.position, color: p.color.trim() };
}
else if (typeof p.role_id === "number" && !isNaN(p.role_id)) {
return { position: p.position, role_id: p.role_id };
}
// This should never happen due to the filter above, but TypeScript needs this
throw new Error("Invalid placement after filter");
});
for (const climbPlacement of validPlacements) {
if (tempArray.length + 3 > KilterBoard.messageBodyMaxLength) {
resultArray.push(tempArray);
tempArray = [KilterBoardPacket.V3_MIDDLE];
}
let colorHex;
// Type guard: check if this placement has a color property
if ("color" in climbPlacement && climbPlacement.color) {
// Use direct color if provided
colorHex = climbPlacement.color.replace("#", "").toUpperCase();
}
else if ("role_id" in climbPlacement && typeof climbPlacement.role_id === "number") {
// Fall back to role_id if no color provided
const role = KilterBoardPlacementRoles.find((placement) => placement.id === climbPlacement.role_id);
if (!role) {
throw new Error(`Role with id ${climbPlacement.role_id} not found in placement_roles`);
}
colorHex = role.led_color;
}
else {
// This should never happen due to the filter above, but just in case
throw new Error(`Either role_id or color must be provided for position ${climbPlacement.position}`);
}
const encodedPlacement = this.encodePlacement(climbPlacement.position, colorHex);
tempArray.push(...encodedPlacement);
}
resultArray.push(tempArray);
if (resultArray.length === 1) {
resultArray[0][0] = KilterBoardPacket.V3_ONLY;
}
else if (resultArray.length > 1) {
resultArray[0][0] = KilterBoardPacket.V3_FIRST;
resultArray[resultArray.length - 1][0] = KilterBoardPacket.V3_LAST;
}
const finalResultArray = [];
for (const currentArray of resultArray) {
finalResultArray.push(...this.wrapBytes(currentArray));
}
return finalResultArray;
}
/**
* Splits a collection into slices of the specified length.
* https://github.com/ramda/ramda/blob/master/source/splitEvery
* @param {Number} n
* @param {Array} list
* @return {Array<number[]>}
*/
splitEvery(n, list) {
if (n <= 0) {
throw new Error("First argument to splitEvery must be a positive integer");
}
const result = [];
let idx = 0;
while (idx < list.length) {
result.push(list.slice(idx, (idx += n)));
}
return result;
}
/**
* The kilter board only supports messages of 20 bytes
* at a time. This method splits a full message into parts
* of 20 bytes
*
* @param buffer
*/
splitMessages = (buffer) => this.splitEvery(KilterBoard.maxBluetoothMessageSize, buffer).map((arr) => new Uint8Array(arr));
/**
* Sends a series of messages to a device.
*/
async writeMessageSeries(messages) {
for (const message of messages) {
await this.write("uart", "tx", message);
}
}
/**
* Configures the LEDs based on an array of climb placements.
* @param {{ position: number; role_id?: number; color?: string }[]} config - Array of climb placements for the LEDs. Either role_id or color (hex string) must be 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) => {
// Handle Kilterboard logic: process placements and send payload if connected
if (Array.isArray(config)) {
// Prepares byte arrays for transmission based on a list of climb placements.
const payload = this.prepBytesV3(config);
if (this.isConnected()) {
await this.writeMessageSeries(this.splitMessages(payload));
}
return payload;
}
return undefined;
};
}
//# sourceMappingURL=kilterboard.model.js.map