@hangtime/grip-connect 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,23 +2,42 @@
2
2
 
3
3
  **Force-Sensing Climbing Training**
4
4
 
5
- The objective of this project is to create a client that can establish connections with various Force-Sensing Hangboards
6
- / Plates used by climbers for strength measurement. Examples of such hangboards include the
5
+ The objective of this project is to create a Web Bluetooth API client that can establish connections with various
6
+ Force-Sensing Hangboards / Plates used by climbers for strength measurement. Examples of such hangboards include the
7
7
  [Motherboard](https://griptonite.io/shop/motherboard/), [Climbro](https://climbro.com/),
8
8
  [SmartBoard](https://www.smartboard-climbing.com/), [Entralpi](https://entralpi.com/) or
9
9
  [Tindeq Progressor](https://tindeq.com/)
10
10
 
11
+ [Try it out](https://grip-connect.vercel.app/) - [Docs](https://stevie-ray.github.io/hangtime-grip-connect/) -
12
+ [Browser Support](https://caniuse.com/web-bluetooth)
13
+
11
14
  ## Roadmap
12
15
 
13
- - ➡️ Connect with devices
14
- - Griptonte Motherboard
15
- - Tindeq Progressor
16
- - Entralpi
17
- - ➡️ Climbro
18
- - ➡️ SmartBoard
19
- - Read / Write / Notify using Bluetooth
20
- - ➡️ Calibrate Devices
21
- - ➡️ Output weight/force stream
16
+ - Griptonte Motherboard
17
+ - ✅️ Connect with devices
18
+ - ✅️ Read / Write / Notify using Bluetooth
19
+ - ➡️ Calibrate Devices
20
+ - ✅️ Output weight/force stream
21
+ - Tindeq Progressor
22
+ - ✅️ Connect with devices
23
+ - ✅️ Read / Write / Notify using Bluetooth
24
+ - ➡️ Calibrate Devices
25
+ - ➡️ Output weight/force stream
26
+ - ✅ Entralpi
27
+ - ✅️ Connect with devices
28
+ - ✅️ Read / Write / Notify using Bluetooth
29
+ - ➡️ Calibrate Devices
30
+ - ➡️ Output weight/force stream
31
+ - ➡️ Climbro
32
+ - ➡️ Connect with devices
33
+ - ➡️ Read / Write / Notify using Bluetooth
34
+ - ➡️ Calibrate Devices
35
+ - ➡️ Output weight/force stream
36
+ - ➡️ SmartBoard
37
+ - ➡️ Connect with devices
38
+ - ➡️ Read / Write / Notify using Bluetooth
39
+ - ➡️ Calibrate Devices
40
+ - ➡️ Output weight/force stream
22
41
 
23
42
  ## Development
24
43
 
@@ -56,30 +75,41 @@ motherboardButton.addEventListener("click", () => {
56
75
  })
57
76
 
58
77
  // read battery + device info
59
- await read(Motherboard, "battery", "level")
60
- await read(Motherboard, "device", "manufacturer")
61
- await read(Motherboard, "device", "hardware")
62
- await read(Motherboard, "device", "firmware")
78
+ await read(Motherboard, "battery", "level", 1000)
79
+ await read(Motherboard, "device", "manufacturer", 1000)
80
+ await read(Motherboard, "device", "hardware", 1000)
81
+ await read(Motherboard, "device", "firmware", 1000)
63
82
 
64
- // Calibrate?
65
- await write(Motherboard, "uart", "tx", "C", 5000)
83
+ // recalibrate
84
+ await write(Motherboard, "uart", "tx", "", 0)
85
+ await write(Motherboard, "uart", "tx", "", 0)
86
+ await write(Motherboard, "uart", "tx", "", 1000)
66
87
 
67
- // Read stream?
68
- await write(Motherboard, "unknown", "01", "1", 2500)
69
- await write(Motherboard, "unknown", "02", "0", 2500)
70
- await write(Motherboard, "uart", "tx", "S30", 5000)
88
+ await write(Motherboard, "uart", "tx", "C3,0,0,0", 5000)
71
89
 
72
- // Read stream (2x)?
73
- await write(Motherboard, "unknown", "01", "0", 2500)
74
- await write(Motherboard, "unknown", "02", "1", 2500)
75
- await write(Motherboard, "uart", "tx", "S30", 5000)
90
+ // start stream
91
+ await write(Motherboard, "uart", "tx", "S20", 15000)
76
92
 
93
+ // end stream
94
+ await write(Motherboard, "uart", "tx", "", 0)
77
95
  // disconnect from device after we are done
78
96
  disconnect(Motherboard)
79
97
  })
80
98
  })
81
99
  ```
82
100
 
101
+ ## Credits
102
+
103
+ A special thank you to:
104
+
105
+ - [@CassimLadha](https://github.com/CassimLadha) for sharing insights on reading the Motherboards data.
106
+ - [@donaldharvey](https://github.com/donaldharvey) for a valuable example on connecting to the motherboard.
107
+
108
+ ## Disclamer
109
+
110
+ THIS SOFTWARE IS NOT OFFICIALY SUPPORTED, SUPPLIED OR MAINTAINED BY THE DEVICE MANUFACTURER. BY USING THE SOFTWARE YOU
111
+ ARE ACKNOWLEDGEING THIS AND UNDERSTAND THAT USING THIS SOFTWARE WILL INVALIDATE THE MANUFACTURERS WARRANTY.
112
+
83
113
  ## License
84
114
 
85
- MIT © [Stevie-Ray Hartog](https://github.com/Stevie-Ray)
115
+ BSD 2-Clause © [Stevie-Ray Hartog](https://github.com/Stevie-Ray)
@@ -1,6 +1,6 @@
1
1
  import { Device } from "./devices/types";
2
2
  /**
3
- * connect
3
+ * Connect to the BluetoothDevice
4
4
  * @param device
5
5
  * @param onSuccess
6
6
  */
package/build/connect.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { notifyCallback } from "./notify";
2
+ import { handleMotherboardData } from "./devices/moterboard";
3
+ let server;
4
+ const receiveBuffer = [];
2
5
  /**
3
6
  * onDisconnected
4
7
  * @param board
@@ -16,63 +19,83 @@ const onDisconnected = (event, board) => {
16
19
  */
17
20
  const handleNotifications = (event, board) => {
18
21
  const characteristic = event.target;
19
- const receivedData = new Uint8Array(characteristic.value.buffer);
20
- // Create an array to store the parsed decimal values
21
- const decimalArray = [];
22
- // Iterate through each byte and convert to decimal
23
- for (let i = 0; i < receivedData.length; i++) {
24
- decimalArray.push(receivedData[i]);
25
- }
26
- // Convert the decimal array to a string representation
27
- const receivedString = String.fromCharCode(...decimalArray);
28
- if (board.name === "Motherboard") {
29
- // Split the string into pairs of characters
30
- const hexPairs = receivedString.match(/.{1,2}/g);
31
- // Convert each hexadecimal pair to decimal
32
- const parsedDecimalArray = hexPairs?.map((hexPair) => parseInt(hexPair, 16));
33
- // Handle different types of data
34
- if (characteristic.value.byteLength === 20) {
35
- const elementKeys = [
36
- "frames",
37
- "cycle",
38
- "unknown",
39
- "eleven",
40
- "dynamic1",
41
- "pressure1",
42
- "left",
43
- "dynamic2",
44
- "pressure2",
45
- "right",
46
- ];
47
- const dataObject = {};
48
- if (parsedDecimalArray) {
49
- elementKeys.forEach((key, index) => {
50
- dataObject[key] = parsedDecimalArray[index];
51
- });
22
+ const value = characteristic.value;
23
+ if (value) {
24
+ if (board.name === "Motherboard") {
25
+ if (value) {
26
+ for (let i = 0; i < value.byteLength; i++) {
27
+ receiveBuffer.push(value.getUint8(i));
28
+ }
29
+ let idx;
30
+ while ((idx = receiveBuffer.indexOf(10)) >= 0) {
31
+ const line = receiveBuffer.splice(0, idx + 1).slice(0, -1); // Combine and remove LF
32
+ if (line.length > 0 && line[line.length - 1] === 13)
33
+ line.pop(); // Remove CR
34
+ const decoder = new TextDecoder("utf-8");
35
+ const receivedString = decoder.decode(new Uint8Array(line));
36
+ handleMotherboardData(characteristic.uuid, receivedString);
37
+ }
52
38
  }
39
+ }
40
+ else if (board.name === "ENTRALPI") {
41
+ // TODO: handle Entralpi notify
42
+ // characteristic.value!.getInt16(0) / 100;
53
43
  if (notifyCallback) {
54
- notifyCallback({ uuid: characteristic.uuid, value: dataObject });
44
+ notifyCallback({ uuid: characteristic.uuid, value: value });
55
45
  }
56
46
  }
57
- else if (characteristic.value.byteLength === 14) {
58
- // TODO: handle 14 byte data
59
- // notifyCallback({ uuid: characteristic.uuid, value: characteristic.value!.getInt8(0) / 100 })
47
+ else if (board.name === "Tindeq") {
48
+ // TODO: handle Tindeq notify
60
49
  }
61
- }
62
- else if (board.name === "ENTRALPI") {
63
- // TODO: handle Entralpi notify
64
- // characteristic.value!.getInt16(0) / 100;
65
- if (notifyCallback) {
66
- notifyCallback({ uuid: characteristic.uuid, value: receivedString });
50
+ else {
51
+ if (notifyCallback) {
52
+ notifyCallback({ uuid: characteristic.uuid, value: value });
53
+ }
67
54
  }
68
55
  }
69
- else if (board.name === "Tindeq") {
70
- // TODO: handle Tindeq notify
71
- }
72
- else {
73
- if (notifyCallback) {
74
- notifyCallback({ uuid: characteristic.uuid, value: receivedString });
56
+ };
57
+ /**
58
+ * onConnected
59
+ * @param event
60
+ * @param board
61
+ */
62
+ const onConnected = async (board, onSuccess) => {
63
+ try {
64
+ const services = await server?.getPrimaryServices();
65
+ if (!services || services.length === 0) {
66
+ console.error("No services found");
67
+ return;
75
68
  }
69
+ for (const service of services) {
70
+ const matchingService = board.services.find((boardService) => boardService.uuid === service.uuid);
71
+ if (matchingService) {
72
+ // Android bug: Introduce a delay before getting characteristics
73
+ await new Promise((resolve) => setTimeout(resolve, 100));
74
+ const characteristics = await service.getCharacteristics();
75
+ for (const characteristic of matchingService.characteristics) {
76
+ const matchingCharacteristic = characteristics.find((char) => char.uuid === characteristic.uuid);
77
+ if (matchingCharacteristic) {
78
+ const element = matchingService.characteristics.find((char) => char.uuid === matchingCharacteristic.uuid);
79
+ if (element) {
80
+ element.characteristic = matchingCharacteristic;
81
+ // notify
82
+ if (element.id === "rx") {
83
+ matchingCharacteristic.startNotifications();
84
+ matchingCharacteristic.addEventListener("characteristicvaluechanged", (event) => handleNotifications(event, board));
85
+ }
86
+ }
87
+ }
88
+ else {
89
+ console.warn(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ // Call the onSuccess callback after successful connection and setup
95
+ onSuccess();
96
+ }
97
+ catch (error) {
98
+ console.error(error);
76
99
  }
77
100
  };
78
101
  /**
@@ -83,7 +106,7 @@ function getAllServiceUUIDs(device) {
83
106
  return device.services.map((service) => service.uuid);
84
107
  }
85
108
  /**
86
- * connect
109
+ * Connect to the BluetoothDevice
87
110
  * @param device
88
111
  * @param onSuccess
89
112
  */
@@ -111,37 +134,15 @@ export const connect = async (board, onSuccess) => {
111
134
  optionalServices: deviceServices,
112
135
  });
113
136
  board.device = device;
114
- device.addEventListener("gattserverdisconnected", (event) => onDisconnected(event, board));
115
- const server = await device.gatt?.connect();
116
- const services = await server?.getPrimaryServices();
117
- if (!services || services.length === 0) {
118
- console.error("No services found");
137
+ if (!board.device.gatt) {
138
+ console.error("GATT is not available on this device");
119
139
  return;
120
140
  }
121
- for (const service of services) {
122
- const matchingService = board.services.find((boardService) => boardService.uuid === service.uuid);
123
- if (matchingService) {
124
- const characteristics = await service.getCharacteristics();
125
- for (const characteristic of matchingService.characteristics) {
126
- const matchingCharacteristic = characteristics.find((char) => char.uuid === characteristic.uuid);
127
- if (matchingCharacteristic) {
128
- const element = matchingService.characteristics.find((char) => char.uuid === matchingCharacteristic.uuid);
129
- if (element) {
130
- element.characteristic = matchingCharacteristic;
131
- // notify
132
- if (element.id === "rx") {
133
- matchingCharacteristic.startNotifications();
134
- matchingCharacteristic.addEventListener("characteristicvaluechanged", (event) => handleNotifications(event, board));
135
- }
136
- }
137
- }
138
- else {
139
- console.warn(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`);
140
- }
141
- }
142
- }
141
+ server = await board.device?.gatt?.connect();
142
+ board.device.addEventListener("gattserverdisconnected", (event) => onDisconnected(event, board));
143
+ if (server.connected) {
144
+ await onConnected(board, onSuccess);
143
145
  }
144
- onSuccess();
145
146
  }
146
147
  catch (error) {
147
148
  console.error(error);
@@ -1,2 +1,7 @@
1
1
  import { Device } from "./types";
2
2
  export declare const Motherboard: Device;
3
+ /**
4
+ * handleMotherboardData
5
+ * @param line
6
+ */
7
+ export declare function handleMotherboardData(uuid: string, receivedString: string): void;
@@ -1,3 +1,7 @@
1
+ import { notifyCallback } from "../notify";
2
+ const PACKET_LENGTH = 32;
3
+ const NUM_SAMPLES = 3;
4
+ const CALIBRATION = [[], [], [], []];
1
5
  export const Motherboard = {
2
6
  name: "Motherboard",
3
7
  companyId: 0x2a29,
@@ -77,3 +81,83 @@ export const Motherboard = {
77
81
  },
78
82
  ],
79
83
  };
84
+ /**
85
+ * applyCalibration
86
+ * @param sample
87
+ * @param calibration
88
+ */
89
+ const applyCalibration = (sample, calibration) => {
90
+ const zeroCalib = calibration[0][2];
91
+ let sgn = 1;
92
+ let final = 0;
93
+ if (sample < zeroCalib) {
94
+ sgn = -1;
95
+ sample = 2 * zeroCalib - sample;
96
+ }
97
+ for (let i = 1; i < calibration.length; i++) {
98
+ const calibStart = calibration[i - 1][2];
99
+ const calibEnd = calibration[i][2];
100
+ if (sample < calibEnd) {
101
+ final =
102
+ calibration[i - 1][1] +
103
+ ((sample - calibStart) / (calibEnd - calibStart)) * (calibration[i][1] - calibration[i - 1][1]);
104
+ break;
105
+ }
106
+ }
107
+ return sgn * final;
108
+ };
109
+ /**
110
+ * handleMotherboardData
111
+ * @param line
112
+ */
113
+ export function handleMotherboardData(uuid, receivedString) {
114
+ const receivedTime = Date.now();
115
+ // Check if the line is entirely hex characters
116
+ const allHex = /^[0-9A-Fa-f]+$/g.test(receivedString);
117
+ // Decide if this is a streaming packet
118
+ if (allHex && receivedString.length === PACKET_LENGTH) {
119
+ // Base-16 decode the string: convert hex pairs to byte values
120
+ const bytes = Array.from({ length: receivedString.length / 2 }, (_, i) => Number(`0x${receivedString.substring(i * 2, i * 2 + 2)}`));
121
+ // Translate header into packet, number of samples from the packet length
122
+ const packet = {
123
+ received: receivedTime,
124
+ sampleNum: new DataView(new Uint8Array(bytes).buffer).getUint16(0, true),
125
+ battRaw: new DataView(new Uint8Array(bytes).buffer).getUint16(2, true),
126
+ samples: [],
127
+ masses: [],
128
+ };
129
+ for (let i = 0; i < NUM_SAMPLES; i++) {
130
+ const sampleStart = 4 + 3 * i;
131
+ packet.samples[i] = bytes[sampleStart] | (bytes[sampleStart + 1] << 8) | (bytes[sampleStart + 2] << 16);
132
+ if (packet.samples[i] >= 0x7fffff) {
133
+ packet.samples[i] -= 0x1000000;
134
+ }
135
+ // TODO: make sure device is calibrated
136
+ if (!CALIBRATION[0].length)
137
+ return;
138
+ packet.masses[i] = applyCalibration(packet.samples[i], CALIBRATION[i]);
139
+ }
140
+ const left = packet.masses[0];
141
+ const center = packet.masses[1];
142
+ const right = packet.masses[2];
143
+ notifyCallback({
144
+ uuid,
145
+ value: {
146
+ massTotal: Math.max(-1000, left + right + center).toFixed(3),
147
+ massLeft: Math.max(-1000, left).toFixed(3),
148
+ massRight: Math.max(-1000, right).toFixed(3),
149
+ massCentre: Math.max(-1000, center).toFixed(3),
150
+ },
151
+ });
152
+ }
153
+ else if ((receivedString.match(/,/g) || []).length === 3) {
154
+ // if the returned notification is a calibration string add them to the array
155
+ const parts = receivedString.split(",");
156
+ const numericParts = parts.map((x) => parseFloat(x));
157
+ CALIBRATION[numericParts[0]].push(numericParts.slice(1));
158
+ }
159
+ else {
160
+ // unhanded data
161
+ console.log(receivedString);
162
+ }
163
+ }
package/build/read.d.ts CHANGED
@@ -3,4 +3,4 @@ import { Device } from "./devices/types";
3
3
  * read
4
4
  * @param characteristic
5
5
  */
6
- export declare const read: (board: Device, serviceId: string, characteristicId: string) => Promise<void>;
6
+ export declare const read: (board: Device, serviceId: string, characteristicId: string, duration?: number) => Promise<void>;
package/build/read.js CHANGED
@@ -4,7 +4,7 @@ import { getCharacteristic } from "./characteristic";
4
4
  * read
5
5
  * @param characteristic
6
6
  */
7
- export const read = (board, serviceId, characteristicId) => {
7
+ export const read = (board, serviceId, characteristicId, duration = 0) => {
8
8
  return new Promise((resolve, reject) => {
9
9
  if (board.device?.gatt?.connected) {
10
10
  const characteristic = getCharacteristic(board, serviceId, characteristicId);
@@ -22,8 +22,12 @@ export const read = (board, serviceId, characteristicId) => {
22
22
  decodedValue = decoder.decode(value);
23
23
  break;
24
24
  }
25
- notifyCallback({ uuid: characteristic.uuid, value: decodedValue });
26
- resolve();
25
+ if (notifyCallback) {
26
+ notifyCallback({ uuid: characteristic.uuid, value: decodedValue });
27
+ }
28
+ setTimeout(() => {
29
+ resolve();
30
+ }, duration);
27
31
  })
28
32
  .catch((error) => {
29
33
  reject(error);
package/build/write.js CHANGED
@@ -10,8 +10,9 @@ export const write = (board, serviceId, characteristicId, message, duration = 0)
10
10
  const encoder = new TextEncoder();
11
11
  const characteristic = getCharacteristic(board, serviceId, characteristicId);
12
12
  if (characteristic) {
13
+ const value = message + "\n";
13
14
  characteristic
14
- .writeValue(encoder.encode(message))
15
+ .writeValue(encoder.encode(value))
15
16
  .then(() => {
16
17
  setTimeout(() => {
17
18
  resolve();
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@hangtime/grip-connect",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "A client that can establish connections with various Force-Sensing Hangboards/Plates used by climbers for strength measurement. Examples of such hangboards include the Motherboard, Climbro, SmartBoard, Entralpi or Tindeq Progressor",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
7
7
  "build": "tsc --build"
8
8
  },
9
9
  "author": "Stevie-Ray Hartog <mail@stevie-ray.nl>",
10
- "license": "MIT",
10
+ "license": "BSD-2-Clause",
11
11
  "devDependencies": {},
12
12
  "repository": {
13
13
  "type": "git",
@@ -0,0 +1,9 @@
1
+ /// <reference types="web-bluetooth" />
2
+ import { Device } from "./devices/types";
3
+ /**
4
+ * getCharacteristic
5
+ * @param board
6
+ * @param serviceId
7
+ * @param characteristicId
8
+ */
9
+ export declare const getCharacteristic: (board: Device, serviceId: string, characteristicId: string) => BluetoothRemoteGATTCharacteristic | undefined;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * getCharacteristic
3
+ * @param board
4
+ * @param serviceId
5
+ * @param characteristicId
6
+ */
7
+ export const getCharacteristic = (board, serviceId, characteristicId) => {
8
+ const boardService = board.services.find((service) => service.id === serviceId);
9
+ if (boardService) {
10
+ const boardCharacteristic = boardService.characteristics.find((characteristic) => characteristic.id === characteristicId);
11
+ if (boardCharacteristic) {
12
+ return boardCharacteristic.characteristic;
13
+ }
14
+ }
15
+ };
package/src/connect.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Device } from "./devices/types";
2
2
  /**
3
- * connect
3
+ * Connect to the BluetoothDevice
4
4
  * @param device
5
5
  * @param onSuccess
6
6
  */