@hangtime/grip-connect 0.0.7 → 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 +52 -22
- package/build/connect.js +30 -52
- package/build/devices/moterboard.d.ts +5 -0
- package/build/devices/moterboard.js +84 -0
- package/build/write.js +2 -1
- package/package.json +2 -2
- package/src/connect.js +30 -52
- package/src/connect.ts +30 -54
- package/src/devices/moterboard.d.ts +5 -0
- package/src/devices/moterboard.js +84 -0
- package/src/devices/moterboard.ts +106 -0
- package/src/read.ts +6 -1
- package/src/write.js +2 -1
- package/src/write.ts +2 -1
- package/tsconfig.json +2 -2
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
|
|
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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
- ➡️
|
|
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
|
|
|
@@ -61,25 +80,36 @@ motherboardButton.addEventListener("click", () => {
|
|
|
61
80
|
await read(Motherboard, "device", "hardware", 1000)
|
|
62
81
|
await read(Motherboard, "device", "firmware", 1000)
|
|
63
82
|
|
|
64
|
-
//
|
|
65
|
-
await write(Motherboard, "uart", "tx", "
|
|
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
|
-
|
|
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
|
-
//
|
|
73
|
-
await write(Motherboard, "
|
|
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
|
-
|
|
115
|
+
BSD 2-Clause © [Stevie-Ray Hartog](https://github.com/Stevie-Ray)
|
package/build/connect.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { notifyCallback } from "./notify";
|
|
2
|
+
import { handleMotherboardData } from "./devices/moterboard";
|
|
2
3
|
let server;
|
|
4
|
+
const receiveBuffer = [];
|
|
3
5
|
/**
|
|
4
6
|
* onDisconnected
|
|
5
7
|
* @param board
|
|
@@ -17,62 +19,38 @@ const onDisconnected = (event, board) => {
|
|
|
17
19
|
*/
|
|
18
20
|
const handleNotifications = (event, board) => {
|
|
19
21
|
const characteristic = event.target;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const elementKeys = [
|
|
37
|
-
"frames",
|
|
38
|
-
"cycle",
|
|
39
|
-
"unknown",
|
|
40
|
-
"eleven",
|
|
41
|
-
"dynamic1",
|
|
42
|
-
"pressure1",
|
|
43
|
-
"left",
|
|
44
|
-
"dynamic2",
|
|
45
|
-
"pressure2",
|
|
46
|
-
"right",
|
|
47
|
-
];
|
|
48
|
-
const dataObject = {};
|
|
49
|
-
if (parsedDecimalArray) {
|
|
50
|
-
elementKeys.forEach((key, index) => {
|
|
51
|
-
dataObject[key] = parsedDecimalArray[index];
|
|
52
|
-
});
|
|
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
|
+
}
|
|
53
38
|
}
|
|
39
|
+
}
|
|
40
|
+
else if (board.name === "ENTRALPI") {
|
|
41
|
+
// TODO: handle Entralpi notify
|
|
42
|
+
// characteristic.value!.getInt16(0) / 100;
|
|
54
43
|
if (notifyCallback) {
|
|
55
|
-
notifyCallback({ uuid: characteristic.uuid, value:
|
|
44
|
+
notifyCallback({ uuid: characteristic.uuid, value: value });
|
|
56
45
|
}
|
|
57
46
|
}
|
|
58
|
-
else if (
|
|
59
|
-
// TODO: handle
|
|
60
|
-
// notifyCallback({ uuid: characteristic.uuid, value: characteristic.value!.getInt8(0) / 100 })
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
else if (board.name === "ENTRALPI") {
|
|
64
|
-
// TODO: handle Entralpi notify
|
|
65
|
-
// characteristic.value!.getInt16(0) / 100;
|
|
66
|
-
if (notifyCallback) {
|
|
67
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString });
|
|
47
|
+
else if (board.name === "Tindeq") {
|
|
48
|
+
// TODO: handle Tindeq notify
|
|
68
49
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else {
|
|
74
|
-
if (notifyCallback) {
|
|
75
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString });
|
|
50
|
+
else {
|
|
51
|
+
if (notifyCallback) {
|
|
52
|
+
notifyCallback({ uuid: characteristic.uuid, value: value });
|
|
53
|
+
}
|
|
76
54
|
}
|
|
77
55
|
}
|
|
78
56
|
};
|
|
@@ -153,7 +131,7 @@ export const connect = async (board, onSuccess) => {
|
|
|
153
131
|
}
|
|
154
132
|
const device = await navigator.bluetooth.requestDevice({
|
|
155
133
|
filters: filters,
|
|
156
|
-
optionalServices: deviceServices
|
|
134
|
+
optionalServices: deviceServices,
|
|
157
135
|
});
|
|
158
136
|
board.device = device;
|
|
159
137
|
if (!board.device.gatt) {
|
|
@@ -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/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(
|
|
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.
|
|
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": "
|
|
10
|
+
"license": "BSD-2-Clause",
|
|
11
11
|
"devDependencies": {},
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
package/src/connect.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { notifyCallback } from "./notify";
|
|
2
|
+
import { handleMotherboardData } from "./devices/moterboard";
|
|
2
3
|
let server;
|
|
4
|
+
const receiveBuffer = [];
|
|
3
5
|
/**
|
|
4
6
|
* onDisconnected
|
|
5
7
|
* @param board
|
|
@@ -17,62 +19,38 @@ const onDisconnected = (event, board) => {
|
|
|
17
19
|
*/
|
|
18
20
|
const handleNotifications = (event, board) => {
|
|
19
21
|
const characteristic = event.target;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const elementKeys = [
|
|
37
|
-
"frames",
|
|
38
|
-
"cycle",
|
|
39
|
-
"unknown",
|
|
40
|
-
"eleven",
|
|
41
|
-
"dynamic1",
|
|
42
|
-
"pressure1",
|
|
43
|
-
"left",
|
|
44
|
-
"dynamic2",
|
|
45
|
-
"pressure2",
|
|
46
|
-
"right",
|
|
47
|
-
];
|
|
48
|
-
const dataObject = {};
|
|
49
|
-
if (parsedDecimalArray) {
|
|
50
|
-
elementKeys.forEach((key, index) => {
|
|
51
|
-
dataObject[key] = parsedDecimalArray[index];
|
|
52
|
-
});
|
|
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
|
+
}
|
|
53
38
|
}
|
|
39
|
+
}
|
|
40
|
+
else if (board.name === "ENTRALPI") {
|
|
41
|
+
// TODO: handle Entralpi notify
|
|
42
|
+
// characteristic.value!.getInt16(0) / 100;
|
|
54
43
|
if (notifyCallback) {
|
|
55
|
-
notifyCallback({ uuid: characteristic.uuid, value:
|
|
44
|
+
notifyCallback({ uuid: characteristic.uuid, value: value });
|
|
56
45
|
}
|
|
57
46
|
}
|
|
58
|
-
else if (
|
|
59
|
-
// TODO: handle
|
|
60
|
-
// notifyCallback({ uuid: characteristic.uuid, value: characteristic.value!.getInt8(0) / 100 })
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
else if (board.name === "ENTRALPI") {
|
|
64
|
-
// TODO: handle Entralpi notify
|
|
65
|
-
// characteristic.value!.getInt16(0) / 100;
|
|
66
|
-
if (notifyCallback) {
|
|
67
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString });
|
|
47
|
+
else if (board.name === "Tindeq") {
|
|
48
|
+
// TODO: handle Tindeq notify
|
|
68
49
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else {
|
|
74
|
-
if (notifyCallback) {
|
|
75
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString });
|
|
50
|
+
else {
|
|
51
|
+
if (notifyCallback) {
|
|
52
|
+
notifyCallback({ uuid: characteristic.uuid, value: value });
|
|
53
|
+
}
|
|
76
54
|
}
|
|
77
55
|
}
|
|
78
56
|
};
|
|
@@ -153,7 +131,7 @@ export const connect = async (board, onSuccess) => {
|
|
|
153
131
|
}
|
|
154
132
|
const device = await navigator.bluetooth.requestDevice({
|
|
155
133
|
filters: filters,
|
|
156
|
-
optionalServices: deviceServices
|
|
134
|
+
optionalServices: deviceServices,
|
|
157
135
|
});
|
|
158
136
|
board.device = device;
|
|
159
137
|
if (!board.device.gatt) {
|
package/src/connect.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Device } from "./devices/types"
|
|
2
2
|
import { notifyCallback } from "./notify"
|
|
3
|
+
import { handleMotherboardData } from "./devices/moterboard"
|
|
3
4
|
|
|
4
5
|
let server: BluetoothRemoteGATTServer
|
|
6
|
+
const receiveBuffer: number[] = []
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* onDisconnected
|
|
@@ -20,61 +22,35 @@ const onDisconnected = (event: Event, board: Device): void => {
|
|
|
20
22
|
*/
|
|
21
23
|
const handleNotifications = (event: Event, board: Device): void => {
|
|
22
24
|
const characteristic = event.target as BluetoothRemoteGATTCharacteristic
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const value = characteristic.value
|
|
26
|
+
if (value) {
|
|
27
|
+
if (board.name === "Motherboard") {
|
|
28
|
+
if (value) {
|
|
29
|
+
for (let i = 0; i < value.byteLength; i++) {
|
|
30
|
+
receiveBuffer.push(value.getUint8(i))
|
|
31
|
+
}
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Split the string into pairs of characters
|
|
36
|
-
const hexPairs: RegExpMatchArray | null = receivedString.match(/.{1,2}/g)
|
|
37
|
-
// Convert each hexadecimal pair to decimal
|
|
38
|
-
const parsedDecimalArray: number[] | undefined = hexPairs?.map((hexPair) => parseInt(hexPair, 16))
|
|
39
|
-
// Handle different types of data
|
|
40
|
-
if (characteristic.value!.byteLength === 20) {
|
|
41
|
-
const elementKeys = [
|
|
42
|
-
"frames",
|
|
43
|
-
"cycle",
|
|
44
|
-
"unknown",
|
|
45
|
-
"eleven",
|
|
46
|
-
"dynamic1",
|
|
47
|
-
"pressure1",
|
|
48
|
-
"left",
|
|
49
|
-
"dynamic2",
|
|
50
|
-
"pressure2",
|
|
51
|
-
"right",
|
|
52
|
-
]
|
|
53
|
-
const dataObject: { [key: string]: number } = {}
|
|
54
|
-
|
|
55
|
-
if (parsedDecimalArray) {
|
|
56
|
-
elementKeys.forEach((key: string, index: number) => {
|
|
57
|
-
dataObject[key] = parsedDecimalArray[index]
|
|
58
|
-
})
|
|
33
|
+
let idx: number
|
|
34
|
+
while ((idx = receiveBuffer.indexOf(10)) >= 0) {
|
|
35
|
+
const line = receiveBuffer.splice(0, idx + 1).slice(0, -1) // Combine and remove LF
|
|
36
|
+
if (line.length > 0 && line[line.length - 1] === 13) line.pop() // Remove CR
|
|
37
|
+
const decoder = new TextDecoder("utf-8")
|
|
38
|
+
const receivedString = decoder.decode(new Uint8Array(line))
|
|
39
|
+
handleMotherboardData(characteristic.uuid, receivedString)
|
|
40
|
+
}
|
|
59
41
|
}
|
|
42
|
+
} else if (board.name === "ENTRALPI") {
|
|
43
|
+
// TODO: handle Entralpi notify
|
|
44
|
+
// characteristic.value!.getInt16(0) / 100;
|
|
60
45
|
if (notifyCallback) {
|
|
61
|
-
notifyCallback({ uuid: characteristic.uuid, value:
|
|
46
|
+
notifyCallback({ uuid: characteristic.uuid, value: value })
|
|
47
|
+
}
|
|
48
|
+
} else if (board.name === "Tindeq") {
|
|
49
|
+
// TODO: handle Tindeq notify
|
|
50
|
+
} else {
|
|
51
|
+
if (notifyCallback) {
|
|
52
|
+
notifyCallback({ uuid: characteristic.uuid, value: value })
|
|
62
53
|
}
|
|
63
|
-
} else if (characteristic.value!.byteLength === 14) {
|
|
64
|
-
// TODO: handle 14 byte data
|
|
65
|
-
// notifyCallback({ uuid: characteristic.uuid, value: characteristic.value!.getInt8(0) / 100 })
|
|
66
|
-
}
|
|
67
|
-
} else if (board.name === "ENTRALPI") {
|
|
68
|
-
// TODO: handle Entralpi notify
|
|
69
|
-
// characteristic.value!.getInt16(0) / 100;
|
|
70
|
-
if (notifyCallback) {
|
|
71
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString })
|
|
72
|
-
}
|
|
73
|
-
} else if (board.name === "Tindeq") {
|
|
74
|
-
// TODO: handle Tindeq notify
|
|
75
|
-
} else {
|
|
76
|
-
if (notifyCallback) {
|
|
77
|
-
notifyCallback({ uuid: characteristic.uuid, value: receivedString })
|
|
78
54
|
}
|
|
79
55
|
}
|
|
80
56
|
}
|
|
@@ -97,7 +73,7 @@ const onConnected = async (board: Device, onSuccess: () => void): Promise<void>
|
|
|
97
73
|
|
|
98
74
|
if (matchingService) {
|
|
99
75
|
// Android bug: Introduce a delay before getting characteristics
|
|
100
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
101
77
|
|
|
102
78
|
const characteristics = await service.getCharacteristics()
|
|
103
79
|
|
|
@@ -166,7 +142,7 @@ export const connect = async (board: Device, onSuccess: () => void): Promise<voi
|
|
|
166
142
|
|
|
167
143
|
const device = await navigator.bluetooth.requestDevice({
|
|
168
144
|
filters: filters,
|
|
169
|
-
optionalServices: deviceServices
|
|
145
|
+
optionalServices: deviceServices,
|
|
170
146
|
})
|
|
171
147
|
|
|
172
148
|
board.device = device
|
|
@@ -181,7 +157,7 @@ export const connect = async (board: Device, onSuccess: () => void): Promise<voi
|
|
|
181
157
|
board.device.addEventListener("gattserverdisconnected", (event) => onDisconnected(event, board))
|
|
182
158
|
|
|
183
159
|
if (server.connected) {
|
|
184
|
-
await onConnected(board, onSuccess)
|
|
160
|
+
await onConnected(board, onSuccess)
|
|
185
161
|
}
|
|
186
162
|
} catch (error) {
|
|
187
163
|
console.error(error)
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Device } from "./types"
|
|
2
|
+
import { notifyCallback } from "../notify"
|
|
3
|
+
|
|
4
|
+
const PACKET_LENGTH: number = 32
|
|
5
|
+
const NUM_SAMPLES: number = 3
|
|
6
|
+
const CALIBRATION = [[], [], [], []]
|
|
2
7
|
|
|
3
8
|
export const Motherboard: Device = {
|
|
4
9
|
name: "Motherboard",
|
|
@@ -79,3 +84,104 @@ export const Motherboard: Device = {
|
|
|
79
84
|
},
|
|
80
85
|
],
|
|
81
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* applyCalibration
|
|
89
|
+
* @param sample
|
|
90
|
+
* @param calibration
|
|
91
|
+
*/
|
|
92
|
+
const applyCalibration = (sample: number, calibration: number[][]): number => {
|
|
93
|
+
const zeroCalib: number = calibration[0][2]
|
|
94
|
+
let sgn: number = 1
|
|
95
|
+
let final: number = 0
|
|
96
|
+
|
|
97
|
+
if (sample < zeroCalib) {
|
|
98
|
+
sgn = -1
|
|
99
|
+
sample = 2 * zeroCalib - sample
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (let i = 1; i < calibration.length; i++) {
|
|
103
|
+
const calibStart: number = calibration[i - 1][2]
|
|
104
|
+
const calibEnd: number = calibration[i][2]
|
|
105
|
+
|
|
106
|
+
if (sample < calibEnd) {
|
|
107
|
+
final =
|
|
108
|
+
calibration[i - 1][1] +
|
|
109
|
+
((sample - calibStart) / (calibEnd - calibStart)) * (calibration[i][1] - calibration[i - 1][1])
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sgn * final
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Packet {
|
|
118
|
+
received: number
|
|
119
|
+
sampleNum: number
|
|
120
|
+
battRaw: number
|
|
121
|
+
samples: number[]
|
|
122
|
+
masses: number[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* handleMotherboardData
|
|
127
|
+
* @param line
|
|
128
|
+
*/
|
|
129
|
+
export function handleMotherboardData(uuid: string, receivedString: string): void {
|
|
130
|
+
const receivedTime: number = Date.now()
|
|
131
|
+
|
|
132
|
+
// Check if the line is entirely hex characters
|
|
133
|
+
const allHex: boolean = /^[0-9A-Fa-f]+$/g.test(receivedString)
|
|
134
|
+
|
|
135
|
+
// Decide if this is a streaming packet
|
|
136
|
+
if (allHex && receivedString.length === PACKET_LENGTH) {
|
|
137
|
+
// Base-16 decode the string: convert hex pairs to byte values
|
|
138
|
+
const bytes: number[] = Array.from({ length: receivedString.length / 2 }, (_, i) =>
|
|
139
|
+
Number(`0x${receivedString.substring(i * 2, i * 2 + 2)}`),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Translate header into packet, number of samples from the packet length
|
|
143
|
+
const packet: Packet = {
|
|
144
|
+
received: receivedTime,
|
|
145
|
+
sampleNum: new DataView(new Uint8Array(bytes).buffer).getUint16(0, true),
|
|
146
|
+
battRaw: new DataView(new Uint8Array(bytes).buffer).getUint16(2, true),
|
|
147
|
+
samples: [],
|
|
148
|
+
masses: [],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < NUM_SAMPLES; i++) {
|
|
152
|
+
const sampleStart: number = 4 + 3 * i
|
|
153
|
+
packet.samples[i] = bytes[sampleStart] | (bytes[sampleStart + 1] << 8) | (bytes[sampleStart + 2] << 16)
|
|
154
|
+
|
|
155
|
+
if (packet.samples[i] >= 0x7fffff) {
|
|
156
|
+
packet.samples[i] -= 0x1000000
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// TODO: make sure device is calibrated
|
|
160
|
+
if (!CALIBRATION[0].length) return
|
|
161
|
+
|
|
162
|
+
packet.masses[i] = applyCalibration(packet.samples[i], CALIBRATION[i])
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const left: number = packet.masses[0]
|
|
166
|
+
const center: number = packet.masses[1]
|
|
167
|
+
const right: number = packet.masses[2]
|
|
168
|
+
|
|
169
|
+
notifyCallback({
|
|
170
|
+
uuid,
|
|
171
|
+
value: {
|
|
172
|
+
massTotal: Math.max(-1000, left + right + center).toFixed(3),
|
|
173
|
+
massLeft: Math.max(-1000, left).toFixed(3),
|
|
174
|
+
massRight: Math.max(-1000, right).toFixed(3),
|
|
175
|
+
massCentre: Math.max(-1000, center).toFixed(3),
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
} else if ((receivedString.match(/,/g) || []).length === 3) {
|
|
179
|
+
// if the returned notification is a calibration string add them to the array
|
|
180
|
+
const parts: string[] = receivedString.split(",")
|
|
181
|
+
const numericParts: number[] = parts.map((x) => parseFloat(x))
|
|
182
|
+
;(CALIBRATION[numericParts[0]] as number[][]).push(numericParts.slice(1))
|
|
183
|
+
} else {
|
|
184
|
+
// unhanded data
|
|
185
|
+
console.log(receivedString)
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { getCharacteristic } from "./characteristic"
|
|
|
6
6
|
* read
|
|
7
7
|
* @param characteristic
|
|
8
8
|
*/
|
|
9
|
-
export const read = (
|
|
9
|
+
export const read = (
|
|
10
|
+
board: Device,
|
|
11
|
+
serviceId: string,
|
|
12
|
+
characteristicId: string,
|
|
13
|
+
duration: number = 0,
|
|
14
|
+
): Promise<void> => {
|
|
10
15
|
return new Promise((resolve, reject) => {
|
|
11
16
|
if (board.device?.gatt?.connected) {
|
|
12
17
|
const characteristic = getCharacteristic(board, serviceId, characteristicId)
|
package/src/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(
|
|
15
|
+
.writeValue(encoder.encode(value))
|
|
15
16
|
.then(() => {
|
|
16
17
|
setTimeout(() => {
|
|
17
18
|
resolve();
|
package/src/write.ts
CHANGED
|
@@ -19,8 +19,9 @@ export const write = (
|
|
|
19
19
|
const characteristic = getCharacteristic(board, serviceId, characteristicId)
|
|
20
20
|
|
|
21
21
|
if (characteristic) {
|
|
22
|
+
const value = message + "\n"
|
|
22
23
|
characteristic
|
|
23
|
-
.writeValue(encoder.encode(
|
|
24
|
+
.writeValue(encoder.encode(value))
|
|
24
25
|
.then(() => {
|
|
25
26
|
setTimeout(() => {
|
|
26
27
|
resolve()
|
package/tsconfig.json
CHANGED