@hangtime/grip-connect 0.3.8 → 0.3.10
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 +75 -66
- package/dist/commands/kilterboard.d.ts +12 -0
- package/dist/commands/kilterboard.js +41 -0
- package/dist/connect.d.ts +3 -2
- package/dist/connect.js +44 -41
- package/dist/download.d.ts +8 -2
- package/dist/download.js +63 -7
- package/dist/led.d.ts +2 -2
- package/dist/led.js +6 -3
- package/package.json +4 -2
- package/src/commands/kilterboard.ts +41 -0
- package/src/connect.ts +49 -40
- package/src/download.ts +67 -7
- package/src/led.ts +9 -7
package/README.md
CHANGED
|
@@ -7,8 +7,7 @@ Force-Sensing Hangboards / Dynamometers / Plates / LED system boards used by cli
|
|
|
7
7
|
the [Griptonite Motherboard](https://griptonite.io/shop/motherboard/), [Climbro](https://climbro.com/),
|
|
8
8
|
[mySmartBoard](https://www.smartboard-climbing.com/), [Entralpi](https://entralpi.com/),
|
|
9
9
|
[Tindeq Progressor](https://tindeq.com/) or
|
|
10
|
-
[Weiheng WH-C06](https://weihengmanufacturer.com/products/wh-c06-bluetooth-300kg-hanging-scale/)
|
|
11
|
-
[MAT Muscle Meter](https://www.matassessment.com/musclemeter).
|
|
10
|
+
[Weiheng WH-C06](https://weihengmanufacturer.com/products/wh-c06-bluetooth-300kg-hanging-scale/).
|
|
12
11
|
|
|
13
12
|
And LED system boards from [Aurora Climbing](https://auroraclimbing.com/) like the
|
|
14
13
|
[Kilter Board](https://settercloset.com/pages/the-kilter-board),
|
|
@@ -28,7 +27,7 @@ Learn more: [Docs](https://stevie-ray.github.io/hangtime-grip-connect/) -
|
|
|
28
27
|
## Try it out
|
|
29
28
|
|
|
30
29
|
[Chart](https://grip-connect.vercel.app/) - [Flappy Bird](https://grip-connect-flappy-bird.vercel.app/) -
|
|
31
|
-
[Kilter Board](https://grip-connect-kilter-board.vercel.app
|
|
30
|
+
[Kilter Board](https://grip-connect-kilter-board.vercel.app/?route=p1083r15p1117r15p1164r12p1185r12p1233r13p1282r13p1303r13p1372r13p1392r14p1505r15)
|
|
32
31
|
|
|
33
32
|
## Install
|
|
34
33
|
|
|
@@ -52,75 +51,85 @@ import { Motherboard, active, battery, connect, disconnect, info, notify, stream
|
|
|
52
51
|
const motherboardButton = document.querySelector("#motherboard")
|
|
53
52
|
|
|
54
53
|
motherboardButton.addEventListener("click", () => {
|
|
55
|
-
connect(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
54
|
+
connect(
|
|
55
|
+
Motherboard,
|
|
56
|
+
async () => {
|
|
57
|
+
// Listen for stream notifications
|
|
58
|
+
notify((data) => {
|
|
59
|
+
// { massTotal: "0", massMax: "0", massAverage: "0", massLeft: "0", massCenter: "0", massRight: "0" }
|
|
60
|
+
console.log(data)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Check if device is being used
|
|
64
|
+
active((value) => {
|
|
65
|
+
console.log(value)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Read battery + device info
|
|
69
|
+
await battery(Motherboard)
|
|
70
|
+
await info(Motherboard)
|
|
71
|
+
|
|
72
|
+
// trigger LEDs
|
|
73
|
+
// await led(device)
|
|
74
|
+
|
|
75
|
+
// Start weight streaming (for a minute) remove parameter for a continues stream
|
|
76
|
+
await stream(Motherboard, 60000)
|
|
77
|
+
|
|
78
|
+
// Manualy tare the device when the stream is running
|
|
79
|
+
// await tare(5000)
|
|
80
|
+
|
|
81
|
+
// Manually call stop method if stream is continues
|
|
82
|
+
// await stop(Motherboard)
|
|
83
|
+
|
|
84
|
+
// Download data as CSV, JSON, or XML (default: CSV) format => timestamp, frame, battery, samples, masses
|
|
85
|
+
// download('json')
|
|
86
|
+
|
|
87
|
+
// Disconnect from device after we are done
|
|
88
|
+
disconnect(Motherboard)
|
|
89
|
+
},
|
|
90
|
+
(error) => {
|
|
91
|
+
// Optinal custom error handeling
|
|
92
|
+
console.error(error.message)
|
|
93
|
+
},
|
|
94
|
+
)
|
|
89
95
|
})
|
|
90
96
|
```
|
|
91
97
|
|
|
92
|
-
##
|
|
98
|
+
## Device support
|
|
93
99
|
|
|
94
|
-
|
|
100
|
+
- ✅ Griptonite - Motherboard
|
|
101
|
+
- ✅ Tindeq - Progressor
|
|
102
|
+
- ✅ Weiheng - WH-C06
|
|
103
|
+
- By default [watchAdvertisements](https://chromestatus.com/feature/5180688812736512) isn't supported . For Chrome,
|
|
104
|
+
enable it at `chrome://flags/#enable-experimental-web-platform-features`.
|
|
105
|
+
- ✅ Kilter Board
|
|
106
|
+
- ⏳ Entralpi (not verified)
|
|
107
|
+
- ➡️ Climbro
|
|
108
|
+
- ➡️ Smartboard Climbing - mySmartBoard
|
|
109
|
+
|
|
110
|
+
## Features
|
|
111
|
+
|
|
112
|
+
**Help wanted:** Do you own any of the missing devices? Use Google Chrome's Bluetooth Internals
|
|
95
113
|
`chrome://bluetooth-internals/#devices` and press `Start Scan` to look for your device, click on `Inspect` and share all
|
|
96
114
|
available services with us.
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
- ✅
|
|
101
|
-
- ✅
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
- ✅
|
|
112
|
-
- ✅
|
|
113
|
-
- ✅
|
|
114
|
-
- ✅
|
|
115
|
-
- ✅ Device info: firmware / serial etc.
|
|
116
|
-
- ✅ Check if device is connected
|
|
117
|
-
- ✅ Check if device is being used
|
|
118
|
-
- ✅ Peak / Average load
|
|
119
|
-
- ✅️ Tare / unladen weight
|
|
120
|
-
- ✅️ Download data to CVS
|
|
121
|
-
- ➡️ Endurance
|
|
122
|
-
- ➡️ Rate of Force Development: RFD
|
|
123
|
-
- ➡️ Critical Force
|
|
116
|
+
| | Motherboard | Progressor | WH-C06 | Entralpi | Kilter Board | Climbro | mySmartBoard |
|
|
117
|
+
| --------------------------------------------------------------------------------------- | ----------- | ---------- | ------ | -------- | ------------ | ------- | ------------ |
|
|
118
|
+
| [Battery](https://stevie-ray.github.io/hangtime-grip-connect/api/battery.html) | ✅ | ✅ | | | | | |
|
|
119
|
+
| [Calibration](https://stevie-ray.github.io/hangtime-grip-connect/api/calibration.html) | ✅ | | | | | | |
|
|
120
|
+
| [Connect](https://stevie-ray.github.io/hangtime-grip-connect/api/connect.html) | ✅ | ✅ | ✅ | ✅ | ✅ | | |
|
|
121
|
+
| [Disconnect](https://stevie-ray.github.io/hangtime-grip-connect/api/disconnect.html) | ✅ | ✅ | ✅ | ✅ | ✅ | | |
|
|
122
|
+
| [Download](https://stevie-ray.github.io/hangtime-grip-connect/api/download.html) | ✅ | ✅ | | | | | |
|
|
123
|
+
| [Info](https://stevie-ray.github.io/hangtime-grip-connect/api/info.html) | ✅ | ✅ | | | | | |
|
|
124
|
+
| [isActive](https://stevie-ray.github.io/hangtime-grip-connect/api/is-active.html) | ✅ | ✅ | ✅ | ✅ | | | |
|
|
125
|
+
| [isConnected](https://stevie-ray.github.io/hangtime-grip-connect/api/is-connected.html) | ✅ | ✅ | ✅ | ✅ | ✅ | | |
|
|
126
|
+
| [Led](https://stevie-ray.github.io/hangtime-grip-connect/api/led.html) | ✅ | | | | ✅ | | |
|
|
127
|
+
| [Notify](https://stevie-ray.github.io/hangtime-grip-connect/api/notify.html) | ✅ | ✅ | ✅ | ✅ | | | |
|
|
128
|
+
| [Read](https://stevie-ray.github.io/hangtime-grip-connect/api/read.html) | ✅ | | | | | | |
|
|
129
|
+
| [Stop](https://stevie-ray.github.io/hangtime-grip-connect/api/stop.html) | ✅ | ✅ | | | | | |
|
|
130
|
+
| [Stream](https://stevie-ray.github.io/hangtime-grip-connect/api/stream.html) | ✅ | ✅ | | | | | |
|
|
131
|
+
| [Tare](https://stevie-ray.github.io/hangtime-grip-connect/api/tare.html) | ✅ | ✅ | ✅ | ✅ | | | |
|
|
132
|
+
| [Write](https://stevie-ray.github.io/hangtime-grip-connect/api/write.html) | ✅ | ✅ | | | | | |
|
|
124
133
|
|
|
125
134
|
## Development
|
|
126
135
|
|
|
@@ -146,7 +155,7 @@ A special thank you to:
|
|
|
146
155
|
- [@1-max-1](https://github.com/1-max-1) for the docs on his Kilter Board
|
|
147
156
|
[simulator](https://github.com/1-max-1/fake_kilter_board) that I coverted to
|
|
148
157
|
[hangtime-arduino-kilterboard](https://github.com/Stevie-Ray/hangtime-arduino-kilterboard).
|
|
149
|
-
- [sebws](https://github.com/sebw) for a [code sample](https://github.com/sebws/Crane) of the Weiheng WH-C06 App.
|
|
158
|
+
- [@sebws](https://github.com/sebw) for a [code sample](https://github.com/sebws/Crane) of the Weiheng WH-C06 App.
|
|
150
159
|
|
|
151
160
|
## Disclaimer
|
|
152
161
|
|
|
@@ -21,3 +21,15 @@ export declare enum KilterBoardPacket {
|
|
|
21
21
|
/** 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. */
|
|
22
22
|
V3_ONLY = 84
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracted from placement_roles database table.
|
|
26
|
+
*/
|
|
27
|
+
export declare const KilterBoardPlacementRoles: {
|
|
28
|
+
id: number;
|
|
29
|
+
product_id: number;
|
|
30
|
+
position: number;
|
|
31
|
+
name: string;
|
|
32
|
+
full_name: string;
|
|
33
|
+
led_color: string;
|
|
34
|
+
screen_color: string;
|
|
35
|
+
}[];
|
|
@@ -22,3 +22,44 @@ export var KilterBoardPacket;
|
|
|
22
22
|
/** 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. */
|
|
23
23
|
KilterBoardPacket[KilterBoardPacket["V3_ONLY"] = 84] = "V3_ONLY";
|
|
24
24
|
})(KilterBoardPacket || (KilterBoardPacket = {}));
|
|
25
|
+
/**
|
|
26
|
+
* Extracted from placement_roles database table.
|
|
27
|
+
*/
|
|
28
|
+
export const KilterBoardPlacementRoles = [
|
|
29
|
+
{
|
|
30
|
+
id: 12,
|
|
31
|
+
product_id: 1,
|
|
32
|
+
position: 1,
|
|
33
|
+
name: "start",
|
|
34
|
+
full_name: "Start",
|
|
35
|
+
led_color: "00FF00",
|
|
36
|
+
screen_color: "00DD00",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 13,
|
|
40
|
+
product_id: 1,
|
|
41
|
+
position: 2,
|
|
42
|
+
name: "middle",
|
|
43
|
+
full_name: "Middle",
|
|
44
|
+
led_color: "00FFFF",
|
|
45
|
+
screen_color: "00FFFF",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 14,
|
|
49
|
+
product_id: 1,
|
|
50
|
+
position: 3,
|
|
51
|
+
name: "finish",
|
|
52
|
+
full_name: "Finish",
|
|
53
|
+
led_color: "FF00FF",
|
|
54
|
+
screen_color: "FF00FF",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 15,
|
|
58
|
+
product_id: 1,
|
|
59
|
+
position: 4,
|
|
60
|
+
name: "foot",
|
|
61
|
+
full_name: "Foot Only",
|
|
62
|
+
led_color: "FFA500",
|
|
63
|
+
screen_color: "FFA500",
|
|
64
|
+
},
|
|
65
|
+
];
|
package/dist/connect.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Device } from "./types/devices";
|
|
|
2
2
|
/**
|
|
3
3
|
* Connects to a Bluetooth device.
|
|
4
4
|
* @param {Device} board - The device to connect to.
|
|
5
|
-
* @param {Function} onSuccess -
|
|
5
|
+
* @param {Function} [onSuccess] - Optional callback function to execute on successful connection. Default logs success.
|
|
6
|
+
* @param {Function} [onError] - Optional callback function to execute on error. Default logs the error.
|
|
6
7
|
*/
|
|
7
|
-
export declare const connect: (board: Device, onSuccess
|
|
8
|
+
export declare const connect: (board: Device, onSuccess?: () => void, onError?: (error: Error) => void) => Promise<void>;
|
package/dist/connect.js
CHANGED
|
@@ -9,7 +9,7 @@ const receiveBuffer = [];
|
|
|
9
9
|
const onDisconnected = (event, board) => {
|
|
10
10
|
board.device = undefined;
|
|
11
11
|
const device = event.target;
|
|
12
|
-
|
|
12
|
+
throw new Error(`Device ${device.name} is disconnected.`);
|
|
13
13
|
};
|
|
14
14
|
/**
|
|
15
15
|
* Handles notifications received from a characteristic.
|
|
@@ -61,46 +61,40 @@ const handleNotifications = (event, board) => {
|
|
|
61
61
|
* @param {Function} onSuccess - Callback function to execute on successful connection.
|
|
62
62
|
*/
|
|
63
63
|
const onConnected = async (board, onSuccess) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
handleNotifications(event, board);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
64
|
+
// Connect to GATT server and set up characteristics
|
|
65
|
+
const services = await server.getPrimaryServices();
|
|
66
|
+
if (!services || services.length === 0) {
|
|
67
|
+
throw new Error("No services found");
|
|
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) => {
|
|
85
|
+
handleNotifications(event, board);
|
|
86
|
+
});
|
|
90
87
|
}
|
|
91
88
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`);
|
|
95
92
|
}
|
|
96
93
|
}
|
|
97
94
|
}
|
|
98
|
-
// Call the onSuccess callback after successful connection and setup
|
|
99
|
-
onSuccess();
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
console.error(error);
|
|
103
95
|
}
|
|
96
|
+
// Call the onSuccess callback after successful connection and setup
|
|
97
|
+
onSuccess();
|
|
104
98
|
};
|
|
105
99
|
/**
|
|
106
100
|
* Returns UUIDs of all services associated with the device.
|
|
@@ -113,9 +107,10 @@ const getAllServiceUUIDs = (device) => {
|
|
|
113
107
|
/**
|
|
114
108
|
* Connects to a Bluetooth device.
|
|
115
109
|
* @param {Device} board - The device to connect to.
|
|
116
|
-
* @param {Function} onSuccess -
|
|
110
|
+
* @param {Function} [onSuccess] - Optional callback function to execute on successful connection. Default logs success.
|
|
111
|
+
* @param {Function} [onError] - Optional callback function to execute on error. Default logs the error.
|
|
117
112
|
*/
|
|
118
|
-
export const connect = async (board, onSuccess) => {
|
|
113
|
+
export const connect = async (board, onSuccess = () => console.log("Connected successfully"), onError = (error) => console.error(error)) => {
|
|
119
114
|
try {
|
|
120
115
|
// Request device and set up connection
|
|
121
116
|
const deviceServices = getAllServiceUUIDs(board);
|
|
@@ -128,8 +123,7 @@ export const connect = async (board, onSuccess) => {
|
|
|
128
123
|
});
|
|
129
124
|
board.device = device;
|
|
130
125
|
if (!board.device.gatt) {
|
|
131
|
-
|
|
132
|
-
return;
|
|
126
|
+
throw new Error("GATT is not available on this device");
|
|
133
127
|
}
|
|
134
128
|
board.device.addEventListener("gattserverdisconnected", (event) => {
|
|
135
129
|
onDisconnected(event, board);
|
|
@@ -145,8 +139,17 @@ export const connect = async (board, onSuccess) => {
|
|
|
145
139
|
handleWHC06Data(manufacturerData);
|
|
146
140
|
}
|
|
147
141
|
});
|
|
142
|
+
// When the companyIdentifier is provided we want to get manufacturerData using watchAdvertisements.
|
|
148
143
|
if (optionalManufacturerData.length) {
|
|
149
|
-
|
|
144
|
+
// Receive events when the system receives an advertisement packet from a watched device.
|
|
145
|
+
// To use this function in Chrome: chrome://flags/#enable-experimental-web-platform-features has to be enabled.
|
|
146
|
+
// More info: https://chromestatus.com/feature/5180688812736512
|
|
147
|
+
if (typeof board.device.watchAdvertisements === "function") {
|
|
148
|
+
await board.device.watchAdvertisements();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
throw new Error("watchAdvertisements isn't supported. For Chrome, enable it at chrome://flags/#enable-experimental-web-platform-features.");
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
154
|
server = await board.device.gatt.connect();
|
|
152
155
|
if (server.connected) {
|
|
@@ -154,6 +157,6 @@ export const connect = async (board, onSuccess) => {
|
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
catch (error) {
|
|
157
|
-
|
|
160
|
+
onError(error);
|
|
158
161
|
}
|
|
159
162
|
};
|
package/dist/download.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ import type { DownloadPacket } from "./types/download";
|
|
|
5
5
|
export declare const DownloadPackets: DownloadPacket[];
|
|
6
6
|
export declare const emptyDownloadPackets: () => void;
|
|
7
7
|
/**
|
|
8
|
-
* Exports the data
|
|
8
|
+
* Exports the data in the specified format (CSV, JSON, XML) with a filename format:
|
|
9
|
+
* 'data-export-YYYY-MM-DD-HH-MM-SS.{format}'.
|
|
10
|
+
*
|
|
11
|
+
* @param {('csv' | 'json' | 'xml')} [format='csv'] - The format in which to download the data.
|
|
12
|
+
* Defaults to 'csv'. Accepted values are 'csv', 'json', and 'xml'.
|
|
13
|
+
*
|
|
14
|
+
* @returns {void} Initiates a download of the data in the specified format.
|
|
9
15
|
*/
|
|
10
|
-
export declare const download: () => void;
|
|
16
|
+
export declare const download: (format?: "csv" | "json" | "xml") => void;
|
package/dist/download.js
CHANGED
|
@@ -26,20 +26,76 @@ const packetsToCSV = (data) => {
|
|
|
26
26
|
.join("\r\n");
|
|
27
27
|
};
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Converts an array of DownloadPacket objects to a JSON string.
|
|
30
|
+
* @param data - Array of DownloadPacket objects.
|
|
31
|
+
* @returns JSON string representation of the data.
|
|
32
|
+
*/
|
|
33
|
+
const packetsToJSON = (data) => {
|
|
34
|
+
return JSON.stringify(data, null, 2); // Pretty print JSON with 2-space indentation
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Converts an array of DownloadPacket objects to an XML string.
|
|
38
|
+
* @param data - Array of DownloadPacket objects.
|
|
39
|
+
* @returns XML string representation of the data.
|
|
40
|
+
*/
|
|
41
|
+
const packetsToXML = (data) => {
|
|
42
|
+
const xmlPackets = data
|
|
43
|
+
.map((packet) => {
|
|
44
|
+
const samples = packet.samples.map((sample) => `<sample>${sample}</sample>`).join("");
|
|
45
|
+
const masses = packet.masses.map((mass) => `<mass>${mass}</mass>`).join("");
|
|
46
|
+
return `
|
|
47
|
+
<packet>
|
|
48
|
+
<received>${packet.received}</received>
|
|
49
|
+
<sampleNum>${packet.sampleNum}</sampleNum>
|
|
50
|
+
<battRaw>${packet.battRaw}</battRaw>
|
|
51
|
+
<samples>${samples}</samples>
|
|
52
|
+
<masses>${masses}</masses>
|
|
53
|
+
</packet>
|
|
54
|
+
`;
|
|
55
|
+
})
|
|
56
|
+
.join("");
|
|
57
|
+
return `<DownloadPackets>${xmlPackets}</DownloadPackets>`;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Exports the data in the specified format (CSV, JSON, XML) with a filename format:
|
|
61
|
+
* 'data-export-YYYY-MM-DD-HH-MM-SS.{format}'.
|
|
62
|
+
*
|
|
63
|
+
* @param {('csv' | 'json' | 'xml')} [format='csv'] - The format in which to download the data.
|
|
64
|
+
* Defaults to 'csv'. Accepted values are 'csv', 'json', and 'xml'.
|
|
65
|
+
*
|
|
66
|
+
* @returns {void} Initiates a download of the data in the specified format.
|
|
30
67
|
*/
|
|
31
|
-
export const download = () => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
export const download = (format = "csv") => {
|
|
69
|
+
let content = "";
|
|
70
|
+
let mimeType = "";
|
|
71
|
+
let fileName = "";
|
|
72
|
+
if (format === "csv") {
|
|
73
|
+
content = packetsToCSV(DownloadPackets);
|
|
74
|
+
mimeType = "text/csv";
|
|
75
|
+
}
|
|
76
|
+
else if (format === "json") {
|
|
77
|
+
content = packetsToJSON(DownloadPackets);
|
|
78
|
+
mimeType = "application/json";
|
|
79
|
+
}
|
|
80
|
+
else if (format === "xml") {
|
|
81
|
+
content = packetsToXML(DownloadPackets);
|
|
82
|
+
mimeType = "application/xml";
|
|
83
|
+
}
|
|
84
|
+
const now = new Date();
|
|
85
|
+
// YYYY-MM-DD
|
|
86
|
+
const date = now.toISOString().split("T")[0];
|
|
87
|
+
// HH-MM-SS
|
|
88
|
+
const time = now.toTimeString().split(" ")[0].replace(/:/g, "-");
|
|
89
|
+
fileName = `data-export-${date}-${time}.${format}`;
|
|
90
|
+
// Create a Blob object containing the data
|
|
91
|
+
const blob = new Blob([content], { type: mimeType });
|
|
36
92
|
// Create a URL for the Blob
|
|
37
93
|
const url = window.URL.createObjectURL(blob);
|
|
38
94
|
// Create a link element
|
|
39
95
|
const link = document.createElement("a");
|
|
40
96
|
// Set link attributes
|
|
41
97
|
link.href = url;
|
|
42
|
-
link.setAttribute("download",
|
|
98
|
+
link.setAttribute("download", fileName);
|
|
43
99
|
// Append link to document body
|
|
44
100
|
document.body.appendChild(link);
|
|
45
101
|
// Programmatically click the link to trigger the download
|
package/dist/led.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Device } from "./types/devices";
|
|
2
2
|
declare class ClimbPlacement {
|
|
3
3
|
position: number;
|
|
4
|
-
role_id:
|
|
5
|
-
constructor(position: number, role_id:
|
|
4
|
+
role_id: number;
|
|
5
|
+
constructor(position: number, role_id: number);
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Prepares byte arrays for transmission based on a list of climb placements.
|
package/dist/led.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { write } from "./write";
|
|
2
2
|
import { isConnected } from "./is-connected";
|
|
3
3
|
import { KilterBoard, Motherboard } from "./devices";
|
|
4
|
-
import { KilterBoardPacket } from "./commands/kilterboard";
|
|
4
|
+
import { KilterBoardPacket, KilterBoardPlacementRoles } from "./commands/kilterboard";
|
|
5
5
|
/**
|
|
6
6
|
* Maximum length of the message body for byte wrapping.
|
|
7
7
|
*/
|
|
@@ -102,8 +102,11 @@ export function prepBytesV3(climbPlacementList) {
|
|
|
102
102
|
resultArray.push(tempArray);
|
|
103
103
|
tempArray = [KilterBoardPacket.V3_MIDDLE];
|
|
104
104
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
105
|
+
const role = KilterBoardPlacementRoles.find((placement) => placement.id === climbPlacement.role_id);
|
|
106
|
+
if (!role) {
|
|
107
|
+
throw new Error(`Role with id ${climbPlacement.role_id} not found in placement_roles`);
|
|
108
|
+
}
|
|
109
|
+
const encodedPlacement = encodePlacement(climbPlacement.position, role.led_color);
|
|
107
110
|
tempArray.push(...encodedPlacement);
|
|
108
111
|
}
|
|
109
112
|
resultArray.push(tempArray);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hangtime/grip-connect",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
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 Griptonite Motherboard, Climbro, SmartBoard, Entralpi or Tindeq Progressor",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"motherboard",
|
|
24
24
|
"tindeq",
|
|
25
25
|
"progressor",
|
|
26
|
-
"entralpi"
|
|
26
|
+
"entralpi",
|
|
27
|
+
"wh-c06",
|
|
28
|
+
"kilterboard"
|
|
27
29
|
],
|
|
28
30
|
"bugs": {
|
|
29
31
|
"url": "https://github.com/Stevie-Ray/hangtime-grip-connect/issues"
|
|
@@ -21,3 +21,44 @@ export enum KilterBoardPacket {
|
|
|
21
21
|
/** 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. */
|
|
22
22
|
V3_ONLY,
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracted from placement_roles database table.
|
|
26
|
+
*/
|
|
27
|
+
export const KilterBoardPlacementRoles = [
|
|
28
|
+
{
|
|
29
|
+
id: 12,
|
|
30
|
+
product_id: 1,
|
|
31
|
+
position: 1,
|
|
32
|
+
name: "start",
|
|
33
|
+
full_name: "Start",
|
|
34
|
+
led_color: "00FF00",
|
|
35
|
+
screen_color: "00DD00",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 13,
|
|
39
|
+
product_id: 1,
|
|
40
|
+
position: 2,
|
|
41
|
+
name: "middle",
|
|
42
|
+
full_name: "Middle",
|
|
43
|
+
led_color: "00FFFF",
|
|
44
|
+
screen_color: "00FFFF",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 14,
|
|
48
|
+
product_id: 1,
|
|
49
|
+
position: 3,
|
|
50
|
+
name: "finish",
|
|
51
|
+
full_name: "Finish",
|
|
52
|
+
led_color: "FF00FF",
|
|
53
|
+
screen_color: "FF00FF",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 15,
|
|
57
|
+
product_id: 1,
|
|
58
|
+
position: 4,
|
|
59
|
+
name: "foot",
|
|
60
|
+
full_name: "Foot Only",
|
|
61
|
+
led_color: "FFA500",
|
|
62
|
+
screen_color: "FFA500",
|
|
63
|
+
},
|
|
64
|
+
]
|
package/src/connect.ts
CHANGED
|
@@ -12,7 +12,7 @@ const receiveBuffer: number[] = []
|
|
|
12
12
|
const onDisconnected = (event: Event, board: Device): void => {
|
|
13
13
|
board.device = undefined
|
|
14
14
|
const device = event.target as BluetoothDevice
|
|
15
|
-
|
|
15
|
+
throw new Error(`Device ${device.name} is disconnected.`)
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Handles notifications received from a characteristic.
|
|
@@ -62,52 +62,47 @@ const handleNotifications = (event: Event, board: Device): void => {
|
|
|
62
62
|
* @param {Function} onSuccess - Callback function to execute on successful connection.
|
|
63
63
|
*/
|
|
64
64
|
const onConnected = async (board: Device, onSuccess: () => void): Promise<void> => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const services: BluetoothRemoteGATTService[] = await server.getPrimaryServices()
|
|
65
|
+
// Connect to GATT server and set up characteristics
|
|
66
|
+
const services: BluetoothRemoteGATTService[] = await server.getPrimaryServices()
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
68
|
+
if (!services || services.length === 0) {
|
|
69
|
+
throw new Error("No services found")
|
|
70
|
+
}
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
for (const service of services) {
|
|
73
|
+
const matchingService = board.services.find((boardService) => boardService.uuid === service.uuid)
|
|
76
74
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
if (matchingService) {
|
|
76
|
+
// Android bug: Introduce a delay before getting characteristics
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
80
78
|
|
|
81
|
-
|
|
79
|
+
const characteristics = await service.getCharacteristics()
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
for (const characteristic of matchingService.characteristics) {
|
|
82
|
+
const matchingCharacteristic = characteristics.find((char) => char.uuid === characteristic.uuid)
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
if (matchingCharacteristic) {
|
|
85
|
+
const element = matchingService.characteristics.find((char) => char.uuid === matchingCharacteristic.uuid)
|
|
86
|
+
if (element) {
|
|
87
|
+
element.characteristic = matchingCharacteristic
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
89
|
+
// notify
|
|
90
|
+
if (element.id === "rx") {
|
|
91
|
+
matchingCharacteristic.startNotifications()
|
|
92
|
+
matchingCharacteristic.addEventListener("characteristicvaluechanged", (event: Event) => {
|
|
93
|
+
handleNotifications(event, board)
|
|
94
|
+
})
|
|
98
95
|
}
|
|
99
|
-
} else {
|
|
100
|
-
console.warn(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`)
|
|
101
96
|
}
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`)
|
|
102
99
|
}
|
|
103
100
|
}
|
|
104
101
|
}
|
|
105
|
-
|
|
106
|
-
// Call the onSuccess callback after successful connection and setup
|
|
107
|
-
onSuccess()
|
|
108
|
-
} catch (error) {
|
|
109
|
-
console.error(error)
|
|
110
102
|
}
|
|
103
|
+
|
|
104
|
+
// Call the onSuccess callback after successful connection and setup
|
|
105
|
+
onSuccess()
|
|
111
106
|
}
|
|
112
107
|
/**
|
|
113
108
|
* Returns UUIDs of all services associated with the device.
|
|
@@ -120,9 +115,14 @@ const getAllServiceUUIDs = (device: Device) => {
|
|
|
120
115
|
/**
|
|
121
116
|
* Connects to a Bluetooth device.
|
|
122
117
|
* @param {Device} board - The device to connect to.
|
|
123
|
-
* @param {Function} onSuccess -
|
|
118
|
+
* @param {Function} [onSuccess] - Optional callback function to execute on successful connection. Default logs success.
|
|
119
|
+
* @param {Function} [onError] - Optional callback function to execute on error. Default logs the error.
|
|
124
120
|
*/
|
|
125
|
-
export const connect = async (
|
|
121
|
+
export const connect = async (
|
|
122
|
+
board: Device,
|
|
123
|
+
onSuccess: () => void = () => console.log("Connected successfully"),
|
|
124
|
+
onError: (error: Error) => void = (error) => console.error(error),
|
|
125
|
+
): Promise<void> => {
|
|
126
126
|
try {
|
|
127
127
|
// Request device and set up connection
|
|
128
128
|
const deviceServices = getAllServiceUUIDs(board)
|
|
@@ -141,8 +141,7 @@ export const connect = async (board: Device, onSuccess: () => void): Promise<voi
|
|
|
141
141
|
board.device = device
|
|
142
142
|
|
|
143
143
|
if (!board.device.gatt) {
|
|
144
|
-
|
|
145
|
-
return
|
|
144
|
+
throw new Error("GATT is not available on this device")
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
board.device.addEventListener("gattserverdisconnected", (event) => {
|
|
@@ -162,8 +161,18 @@ export const connect = async (board: Device, onSuccess: () => void): Promise<voi
|
|
|
162
161
|
}
|
|
163
162
|
})
|
|
164
163
|
|
|
164
|
+
// When the companyIdentifier is provided we want to get manufacturerData using watchAdvertisements.
|
|
165
165
|
if (optionalManufacturerData.length) {
|
|
166
|
-
|
|
166
|
+
// Receive events when the system receives an advertisement packet from a watched device.
|
|
167
|
+
// To use this function in Chrome: chrome://flags/#enable-experimental-web-platform-features has to be enabled.
|
|
168
|
+
// More info: https://chromestatus.com/feature/5180688812736512
|
|
169
|
+
if (typeof board.device.watchAdvertisements === "function") {
|
|
170
|
+
await board.device.watchAdvertisements()
|
|
171
|
+
} else {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"watchAdvertisements isn't supported. For Chrome, enable it at chrome://flags/#enable-experimental-web-platform-features.",
|
|
174
|
+
)
|
|
175
|
+
}
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
server = await board.device.gatt.connect()
|
|
@@ -172,6 +181,6 @@ export const connect = async (board: Device, onSuccess: () => void): Promise<voi
|
|
|
172
181
|
await onConnected(board, onSuccess)
|
|
173
182
|
}
|
|
174
183
|
} catch (error) {
|
|
175
|
-
|
|
184
|
+
onError(error as Error)
|
|
176
185
|
}
|
|
177
186
|
}
|
package/src/download.ts
CHANGED
|
@@ -32,14 +32,74 @@ const packetsToCSV = (data: DownloadPacket[]): string => {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* Converts an array of DownloadPacket objects to a JSON string.
|
|
36
|
+
* @param data - Array of DownloadPacket objects.
|
|
37
|
+
* @returns JSON string representation of the data.
|
|
38
|
+
*/
|
|
39
|
+
const packetsToJSON = (data: DownloadPacket[]): string => {
|
|
40
|
+
return JSON.stringify(data, null, 2) // Pretty print JSON with 2-space indentation
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Converts an array of DownloadPacket objects to an XML string.
|
|
45
|
+
* @param data - Array of DownloadPacket objects.
|
|
46
|
+
* @returns XML string representation of the data.
|
|
47
|
+
*/
|
|
48
|
+
const packetsToXML = (data: DownloadPacket[]): string => {
|
|
49
|
+
const xmlPackets = data
|
|
50
|
+
.map((packet) => {
|
|
51
|
+
const samples = packet.samples.map((sample) => `<sample>${sample}</sample>`).join("")
|
|
52
|
+
const masses = packet.masses.map((mass) => `<mass>${mass}</mass>`).join("")
|
|
53
|
+
return `
|
|
54
|
+
<packet>
|
|
55
|
+
<received>${packet.received}</received>
|
|
56
|
+
<sampleNum>${packet.sampleNum}</sampleNum>
|
|
57
|
+
<battRaw>${packet.battRaw}</battRaw>
|
|
58
|
+
<samples>${samples}</samples>
|
|
59
|
+
<masses>${masses}</masses>
|
|
60
|
+
</packet>
|
|
61
|
+
`
|
|
62
|
+
})
|
|
63
|
+
.join("")
|
|
64
|
+
|
|
65
|
+
return `<DownloadPackets>${xmlPackets}</DownloadPackets>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Exports the data in the specified format (CSV, JSON, XML) with a filename format:
|
|
70
|
+
* 'data-export-YYYY-MM-DD-HH-MM-SS.{format}'.
|
|
71
|
+
*
|
|
72
|
+
* @param {('csv' | 'json' | 'xml')} [format='csv'] - The format in which to download the data.
|
|
73
|
+
* Defaults to 'csv'. Accepted values are 'csv', 'json', and 'xml'.
|
|
74
|
+
*
|
|
75
|
+
* @returns {void} Initiates a download of the data in the specified format.
|
|
36
76
|
*/
|
|
37
|
-
export const download = (): void => {
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
export const download = (format: "csv" | "json" | "xml" = "csv"): void => {
|
|
78
|
+
let content = ""
|
|
79
|
+
let mimeType = ""
|
|
80
|
+
let fileName = ""
|
|
81
|
+
|
|
82
|
+
if (format === "csv") {
|
|
83
|
+
content = packetsToCSV(DownloadPackets)
|
|
84
|
+
mimeType = "text/csv"
|
|
85
|
+
} else if (format === "json") {
|
|
86
|
+
content = packetsToJSON(DownloadPackets)
|
|
87
|
+
mimeType = "application/json"
|
|
88
|
+
} else if (format === "xml") {
|
|
89
|
+
content = packetsToXML(DownloadPackets)
|
|
90
|
+
mimeType = "application/xml"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const now = new Date()
|
|
94
|
+
// YYYY-MM-DD
|
|
95
|
+
const date = now.toISOString().split("T")[0]
|
|
96
|
+
// HH-MM-SS
|
|
97
|
+
const time = now.toTimeString().split(" ")[0].replace(/:/g, "-")
|
|
98
|
+
|
|
99
|
+
fileName = `data-export-${date}-${time}.${format}`
|
|
40
100
|
|
|
41
|
-
// Create a Blob object containing the
|
|
42
|
-
const blob = new Blob([
|
|
101
|
+
// Create a Blob object containing the data
|
|
102
|
+
const blob = new Blob([content], { type: mimeType })
|
|
43
103
|
|
|
44
104
|
// Create a URL for the Blob
|
|
45
105
|
const url = window.URL.createObjectURL(blob)
|
|
@@ -49,7 +109,7 @@ export const download = (): void => {
|
|
|
49
109
|
|
|
50
110
|
// Set link attributes
|
|
51
111
|
link.href = url
|
|
52
|
-
link.setAttribute("download",
|
|
112
|
+
link.setAttribute("download", fileName)
|
|
53
113
|
|
|
54
114
|
// Append link to document body
|
|
55
115
|
document.body.appendChild(link)
|
package/src/led.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Device } from "./types/devices"
|
|
|
2
2
|
import { write } from "./write"
|
|
3
3
|
import { isConnected } from "./is-connected"
|
|
4
4
|
import { KilterBoard, Motherboard } from "./devices"
|
|
5
|
-
import { KilterBoardPacket } from "./commands/kilterboard"
|
|
5
|
+
import { KilterBoardPacket, KilterBoardPlacementRoles } from "./commands/kilterboard"
|
|
6
|
+
|
|
6
7
|
/**
|
|
7
8
|
* Maximum length of the message body for byte wrapping.
|
|
8
9
|
*/
|
|
@@ -46,9 +47,9 @@ function wrapBytes(data: number[]) {
|
|
|
46
47
|
}
|
|
47
48
|
class ClimbPlacement {
|
|
48
49
|
position: number
|
|
49
|
-
role_id:
|
|
50
|
+
role_id: number
|
|
50
51
|
|
|
51
|
-
constructor(position: number, role_id:
|
|
52
|
+
constructor(position: number, role_id: number) {
|
|
52
53
|
this.position = position
|
|
53
54
|
this.role_id = role_id
|
|
54
55
|
}
|
|
@@ -109,10 +110,11 @@ export function prepBytesV3(climbPlacementList: ClimbPlacement[]) {
|
|
|
109
110
|
resultArray.push(tempArray)
|
|
110
111
|
tempArray = [KilterBoardPacket.V3_MIDDLE]
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const role = KilterBoardPlacementRoles.find((placement) => placement.id === climbPlacement.role_id)
|
|
114
|
+
if (!role) {
|
|
115
|
+
throw new Error(`Role with id ${climbPlacement.role_id} not found in placement_roles`)
|
|
116
|
+
}
|
|
117
|
+
const encodedPlacement = encodePlacement(climbPlacement.position, role.led_color)
|
|
116
118
|
tempArray.push(...encodedPlacement)
|
|
117
119
|
}
|
|
118
120
|
|