@hangtime/grip-connect 0.12.0 → 0.13.0

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.
Files changed (104) hide show
  1. package/README.md +11 -0
  2. package/dist/cjs/index.d.ts +2 -2
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js +2 -1
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/interfaces/command.interface.d.ts +110 -21
  7. package/dist/cjs/interfaces/command.interface.d.ts.map +1 -1
  8. package/dist/cjs/interfaces/device/cts500.interface.d.ts +96 -0
  9. package/dist/cjs/interfaces/device/cts500.interface.d.ts.map +1 -0
  10. package/dist/cjs/interfaces/device/cts500.interface.js +3 -0
  11. package/dist/cjs/interfaces/device/cts500.interface.js.map +1 -0
  12. package/dist/cjs/interfaces/device/forceboard.interface.d.ts +2 -2
  13. package/dist/cjs/interfaces/device/forceboard.interface.d.ts.map +1 -1
  14. package/dist/cjs/interfaces/device/progressor.interface.d.ts +2 -2
  15. package/dist/cjs/interfaces/device/progressor.interface.d.ts.map +1 -1
  16. package/dist/cjs/interfaces/index.d.ts +2 -0
  17. package/dist/cjs/interfaces/index.d.ts.map +1 -1
  18. package/dist/cjs/interfaces/nordic.interface.d.ts +47 -0
  19. package/dist/cjs/interfaces/nordic.interface.d.ts.map +1 -0
  20. package/dist/cjs/interfaces/nordic.interface.js +3 -0
  21. package/dist/cjs/interfaces/nordic.interface.js.map +1 -0
  22. package/dist/cjs/models/device/cts500.model.d.ts +173 -0
  23. package/dist/cjs/models/device/cts500.model.d.ts.map +1 -0
  24. package/dist/cjs/models/device/cts500.model.js +588 -0
  25. package/dist/cjs/models/device/cts500.model.js.map +1 -0
  26. package/dist/cjs/models/device/forceboard.model.d.ts +2 -2
  27. package/dist/cjs/models/device/forceboard.model.d.ts.map +1 -1
  28. package/dist/cjs/models/device/forceboard.model.js +3 -14
  29. package/dist/cjs/models/device/forceboard.model.js.map +1 -1
  30. package/dist/cjs/models/device/progressor.model.d.ts +2 -2
  31. package/dist/cjs/models/device/progressor.model.d.ts.map +1 -1
  32. package/dist/cjs/models/device/progressor.model.js +3 -14
  33. package/dist/cjs/models/device/progressor.model.js.map +1 -1
  34. package/dist/cjs/models/device.model.d.ts +7 -0
  35. package/dist/cjs/models/device.model.d.ts.map +1 -1
  36. package/dist/cjs/models/device.model.js +23 -8
  37. package/dist/cjs/models/device.model.js.map +1 -1
  38. package/dist/cjs/models/index.d.ts +2 -0
  39. package/dist/cjs/models/index.d.ts.map +1 -1
  40. package/dist/cjs/models/index.js +6 -1
  41. package/dist/cjs/models/index.js.map +1 -1
  42. package/dist/cjs/models/nordic.model.d.ts +128 -0
  43. package/dist/cjs/models/nordic.model.d.ts.map +1 -0
  44. package/dist/cjs/models/nordic.model.js +405 -0
  45. package/dist/cjs/models/nordic.model.js.map +1 -0
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/interfaces/command.interface.d.ts +110 -21
  51. package/dist/interfaces/command.interface.d.ts.map +1 -1
  52. package/dist/interfaces/device/cts500.interface.d.ts +96 -0
  53. package/dist/interfaces/device/cts500.interface.d.ts.map +1 -0
  54. package/dist/interfaces/device/cts500.interface.js +2 -0
  55. package/dist/interfaces/device/cts500.interface.js.map +1 -0
  56. package/dist/interfaces/device/forceboard.interface.d.ts +2 -2
  57. package/dist/interfaces/device/forceboard.interface.d.ts.map +1 -1
  58. package/dist/interfaces/device/progressor.interface.d.ts +2 -2
  59. package/dist/interfaces/device/progressor.interface.d.ts.map +1 -1
  60. package/dist/interfaces/index.d.ts +2 -0
  61. package/dist/interfaces/index.d.ts.map +1 -1
  62. package/dist/interfaces/nordic.interface.d.ts +47 -0
  63. package/dist/interfaces/nordic.interface.d.ts.map +1 -0
  64. package/dist/interfaces/nordic.interface.js +2 -0
  65. package/dist/interfaces/nordic.interface.js.map +1 -0
  66. package/dist/models/device/cts500.model.d.ts +173 -0
  67. package/dist/models/device/cts500.model.d.ts.map +1 -0
  68. package/dist/models/device/cts500.model.js +584 -0
  69. package/dist/models/device/cts500.model.js.map +1 -0
  70. package/dist/models/device/forceboard.model.d.ts +2 -2
  71. package/dist/models/device/forceboard.model.d.ts.map +1 -1
  72. package/dist/models/device/forceboard.model.js +3 -14
  73. package/dist/models/device/forceboard.model.js.map +1 -1
  74. package/dist/models/device/progressor.model.d.ts +2 -2
  75. package/dist/models/device/progressor.model.d.ts.map +1 -1
  76. package/dist/models/device/progressor.model.js +3 -14
  77. package/dist/models/device/progressor.model.js.map +1 -1
  78. package/dist/models/device.model.d.ts +7 -0
  79. package/dist/models/device.model.d.ts.map +1 -1
  80. package/dist/models/device.model.js +22 -8
  81. package/dist/models/device.model.js.map +1 -1
  82. package/dist/models/index.d.ts +2 -0
  83. package/dist/models/index.d.ts.map +1 -1
  84. package/dist/models/index.js +2 -0
  85. package/dist/models/index.js.map +1 -1
  86. package/dist/models/nordic.model.d.ts +128 -0
  87. package/dist/models/nordic.model.d.ts.map +1 -0
  88. package/dist/models/nordic.model.js +393 -0
  89. package/dist/models/nordic.model.js.map +1 -0
  90. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  91. package/package.json +4 -3
  92. package/src/index.ts +2 -0
  93. package/src/interfaces/command.interface.ts +131 -21
  94. package/src/interfaces/device/cts500.interface.ts +113 -0
  95. package/src/interfaces/device/forceboard.interface.ts +2 -2
  96. package/src/interfaces/device/progressor.interface.ts +2 -2
  97. package/src/interfaces/index.ts +4 -0
  98. package/src/interfaces/nordic.interface.ts +47 -0
  99. package/src/models/device/cts500.model.ts +702 -0
  100. package/src/models/device/forceboard.model.ts +3 -14
  101. package/src/models/device/progressor.model.ts +3 -14
  102. package/src/models/device.model.ts +22 -8
  103. package/src/models/index.ts +4 -0
  104. package/src/models/nordic.model.ts +468 -0
@@ -0,0 +1,702 @@
1
+ import { Device } from "../device.model.js"
2
+ import type { CTS500BaudRate, CTS500SamplingRate, ICTS500 } from "../../interfaces/device/cts500.interface.js"
3
+
4
+ const CTS500_HEADER = 0x05
5
+ const CTS500_RESPONSE_FLAG = 0x80
6
+ const CTS500_ACK_FRAME_LENGTH = 6
7
+ const CTS500_DATA_FRAME_LENGTH = 7
8
+ const CTS500_RESPONSE_TIMEOUT_MS = 2000
9
+
10
+ const CTS500_BAUD_RATE_PARAMS: Record<CTS500BaudRate, number> = {
11
+ 9600: 0x00,
12
+ 19200: 0x01,
13
+ 38400: 0x02,
14
+ 57600: 0x03,
15
+ 115200: 0x04,
16
+ }
17
+
18
+ const CTS500_SAMPLING_RATE_PARAMS: Record<CTS500SamplingRate, number> = {
19
+ 10: 0x00,
20
+ 20: 0x01,
21
+ 40: 0x02,
22
+ 80: 0x03,
23
+ 160: 0x04,
24
+ 320: 0x05,
25
+ }
26
+
27
+ function calculateChecksum(bytes: Uint8Array): number {
28
+ let checksum = 0
29
+
30
+ // CTS frames use a simple additive checksum over every byte before the checksum slot.
31
+ for (const byte of bytes) {
32
+ checksum = (checksum + byte) & 0xff
33
+ }
34
+
35
+ return checksum
36
+ }
37
+
38
+ function buildCommand(opcode: number, payload: readonly [number, number, number] = [0x00, 0x00, 0x00]): Uint8Array {
39
+ const frame = new Uint8Array(CTS500_ACK_FRAME_LENGTH)
40
+ frame[0] = CTS500_HEADER
41
+ frame[1] = opcode
42
+ frame[2] = payload[0]
43
+ frame[3] = payload[1]
44
+ frame[4] = payload[2]
45
+ frame[5] = calculateChecksum(frame.subarray(0, frame.length - 1))
46
+ return frame
47
+ }
48
+
49
+ interface PendingFrame {
50
+ match(frame: Uint8Array): boolean
51
+ reject(error: Error): void
52
+ resolve(frame: Uint8Array): void
53
+ timeout: ReturnType<typeof setTimeout>
54
+ }
55
+
56
+ /**
57
+ * Represents the CTS500 Climbing Training Scale, marketed as "Jlyscales CTS500".
58
+ * Supplier: Hunan Jinlian Cloud Information Technology Co., Ltd.
59
+ * {@link https://www.huaying-scales.com/}
60
+ * {@link https://www.alibaba.com/product-detail/Mini-Climbing-Training-Scale-CTS500-Aluminum_1601637814595.html}
61
+ */
62
+ export class CTS500 extends Device implements ICTS500 {
63
+ private bufferedFrames = new Uint8Array(0)
64
+ private pendingFrame: PendingFrame | undefined = undefined
65
+ private requestQueue: Promise<void> = Promise.resolve()
66
+ private isStreaming = false
67
+ private commandOpcodes = new Set<number>()
68
+
69
+ constructor() {
70
+ super({
71
+ filters: [{ name: "CTS-300" }, { name: "CTS500" }],
72
+ services: [
73
+ {
74
+ name: "Device Information",
75
+ id: "device",
76
+ uuid: "0000180a-0000-1000-8000-00805f9b34fb",
77
+ characteristics: [
78
+ {
79
+ name: "Model Number String",
80
+ id: "model",
81
+ uuid: "00002a24-0000-1000-8000-00805f9b34fb", // MY-BT102 https://www.muyusmart.cn/product/my-bt102/
82
+ },
83
+ // {
84
+ // name: "Serial Number String (Blocked)",
85
+ // id: "serial",
86
+ // uuid: "00002a25-0000-1000-8000-00805f9b34fb",
87
+ // },
88
+ {
89
+ name: "Firmware Revision String",
90
+ id: "firmware",
91
+ uuid: "00002a26-0000-1000-8000-00805f9b34fb", // 109a
92
+ },
93
+ {
94
+ name: "Hardware Revision String",
95
+ id: "hardware",
96
+ uuid: "00002a27-0000-1000-8000-00805f9b34fb", //1.0
97
+ },
98
+ {
99
+ name: "Software Revision String",
100
+ id: "software",
101
+ uuid: "00002a28-0000-1000-8000-00805f9b34fb", // 2.1.3
102
+ },
103
+ {
104
+ name: "Manufacturer Name String",
105
+ id: "manufacturer",
106
+ uuid: "00002a29-0000-1000-8000-00805f9b34fb", // DX
107
+ },
108
+ ],
109
+ },
110
+ {
111
+ name: "CTS500 Service",
112
+ id: "cts500",
113
+ uuid: "0000ffe0-0000-1000-8000-00805f9b34fb",
114
+ characteristics: [
115
+ {
116
+ name: "Notify",
117
+ id: "rx",
118
+ uuid: "0000ffe1-0000-1000-8000-00805f9b34fb",
119
+ },
120
+ {
121
+ name: "Write",
122
+ id: "tx",
123
+ uuid: "0000ffe2-0000-1000-8000-00805f9b34fb",
124
+ },
125
+ ],
126
+ },
127
+ ],
128
+ commands: {
129
+ SET_RANGE: 0x81, // set capacity/range; known presets include 100kg, 200kg, 300kg, 400kg, 500kg, 1T, and 3T
130
+ SET_DIVISION: 0x82, // set division; known presets include 10g, 20g, 50g, and 0.1kg
131
+ SET_FIRST_CALIBRATION_WEIGHT: 0x83, // set first calibration reference weight; known presets include 1kg, 5kg, 10kg, 20kg, 50kg, and 100kg
132
+ SET_SECOND_CALIBRATION_WEIGHT: 0x84, // set second calibration reference weight; known presets shown include 50kg, 100kg, and 200kg
133
+ POWER_ON_RESET: 0x85, // power-on reset mode; payload 00 disables automatic reset and 01 enables it
134
+ ZERO_SCALE: buildCommand(0x86), // update the hardware zero point
135
+ RUN_FIRST_CALIBRATION: buildCommand(0xa1), // run the first calibration step after placing the configured reference weight
136
+ RUN_SECOND_CALIBRATION: buildCommand(0xa2), // run the second calibration step after placing the configured reference weight
137
+ GET_FIRMWARE_VERSION: buildCommand(0xa4), // read firmware version over the transparent UART service
138
+ TARE_SCALE: buildCommand(0xa6), // tare the current load
139
+ NO_LOAD_CALIBRATION: buildCommand(0xa7), // run the no-load calibration routine with the scale unloaded
140
+ GET_WEIGHT: buildCommand(0xa9), // read the current weight immediately
141
+ START_WEIGHT_MEAS: buildCommand(0xaa), // turn on automatic weight uploading
142
+ STOP_WEIGHT_MEAS: buildCommand(0xab), // turn off automatic weight uploading
143
+ SET_BAUD_RATE: 0xc0, // set UART baud rate; payload presets are 00=9600, 01=19200, 02=38400, 03=57600, 04=115200
144
+ SET_SAMPLING_RATE: 0xc1, // set A/D sampling frequency; payload presets are 00=10Hz, 01=20Hz, 02=40Hz, 03=80Hz, 04=160Hz, 05=320Hz
145
+ SET_SHUTDOWN_TIME: 0xc3, // set auto-shutdown timer; the shown presets use payload 1E for 30 seconds and 00 to disable
146
+ GET_BATTERY_VOLTAGE: buildCommand(0xc4), // read battery voltage
147
+ GET_TEMPERATURE: buildCommand(0xc5), // read temperature
148
+ SET_UPPER_TEMPERATURE_LIMIT: 0xc6, // set upper temperature limit; the shown example uses payload 1D
149
+ SET_LOWER_TEMPERATURE_LIMIT: 0xc7, // set lower temperature limit; the shown example uses payload 2A and FF disables the lower limit
150
+ PEAK_MODE: 0xca, // peak mode; payload 00 turns it off and 01 turns it on
151
+ SET_MAX_WEIGHT_LIMIT: 0xd1, // set the upper/max weight limit threshold
152
+ SET_MIN_WEIGHT_LIMIT: 0xd2, // set the lower/min weight limit threshold
153
+ SET_WEIGHT_ALARM_MODE: 0xd3, // set weight alarm mode; payload 00 cancels, 01 alarms inside the range, and 02 alarms outside the range
154
+ SET_ALARM_OUTPUT: 0xd4, // enable or disable alarm-frame output; payload 00 turns it off and 01 turns it on
155
+ },
156
+ })
157
+
158
+ for (const command of Object.values(this.commands)) {
159
+ // Command echoes identify themselves by opcode byte 1 whether the command is stored as a raw opcode or a full frame.
160
+ if (typeof command === "number") {
161
+ this.commandOpcodes.add(command)
162
+ } else if (command instanceof Uint8Array && command.length >= 2) {
163
+ this.commandOpcodes.add(command[1])
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Retrieves battery voltage from the device.
170
+ * The returned string uses two decimal places, e.g. "3.55".
171
+ * @returns {Promise<string | undefined>} A Promise that resolves with the battery voltage.
172
+ */
173
+ battery = async (): Promise<string | undefined> => {
174
+ const command = this.commands.GET_BATTERY_VOLTAGE as Uint8Array
175
+ const frame = await this.queryFrame(command, (response) => this.isCommandResponse(response, command[1]))
176
+ if (!frame) {
177
+ return undefined
178
+ }
179
+
180
+ const rawVoltage = (frame[4] << 8) | frame[5]
181
+ return (rawVoltage / 100).toFixed(2)
182
+ }
183
+
184
+ /**
185
+ * Retrieves firmware version from the device.
186
+ * @returns {Promise<string | undefined>} A Promise that resolves with the firmware version.
187
+ */
188
+ firmware = async (): Promise<string | undefined> => {
189
+ return await this.read("device", "firmware", 250)
190
+ }
191
+
192
+ /**
193
+ * Retrieves hardware version from the device.
194
+ * @returns {Promise<string | undefined>} A Promise that resolves with the hardware version.
195
+ */
196
+ hardware = async (): Promise<string | undefined> => {
197
+ return await this.read("device", "hardware", 250)
198
+ }
199
+
200
+ /**
201
+ * Retrieves manufacturer information from the device.
202
+ * @returns {Promise<string | undefined>} A Promise that resolves with the manufacturer information.
203
+ */
204
+ manufacturer = async (): Promise<string | undefined> => {
205
+ return await this.read("device", "manufacturer", 250)
206
+ }
207
+
208
+ /**
209
+ * Retrieves model number from the device.
210
+ * @returns {Promise<string | undefined>} A Promise that resolves with the model number.
211
+ */
212
+ model = async (): Promise<string | undefined> => {
213
+ return await this.read("device", "model", 250)
214
+ }
215
+
216
+ /**
217
+ * Sets whether the device should reset to zero on power-up.
218
+ * @param {boolean} enabled - Whether power-on reset should be enabled.
219
+ * @returns {Promise<void>} A promise that resolves when the command is acknowledged.
220
+ */
221
+ powerOnReset = async (enabled: boolean): Promise<void> => {
222
+ await this.expectAck(this.commands.POWER_ON_RESET as number, [0x00, 0x00, enabled ? 0x01 : 0x00])
223
+ }
224
+
225
+ /**
226
+ * Enables or disables the device peak mode.
227
+ * @param {boolean} [enabled=true] - Whether peak mode should be enabled.
228
+ * @returns {Promise<void>} A promise that resolves when the command is acknowledged.
229
+ */
230
+ peakMode = async (enabled = true): Promise<void> => {
231
+ await this.expectAck(this.commands.PEAK_MODE as number, [0x00, 0x00, enabled ? 0x01 : 0x00])
232
+ }
233
+
234
+ /**
235
+ * Configures the device UART baud rate.
236
+ * @param {CTS500BaudRate} baudRate - Desired baud rate.
237
+ * @returns {Promise<void>} A promise that resolves when the command is acknowledged.
238
+ */
239
+ setBaudRate = async (baudRate: CTS500BaudRate): Promise<void> => {
240
+ await this.applyConfigCommand(this.commands.SET_BAUD_RATE as number, [
241
+ 0x00,
242
+ 0x00,
243
+ CTS500_BAUD_RATE_PARAMS[baudRate],
244
+ ])
245
+ }
246
+
247
+ /**
248
+ * Configures the device A/D sampling rate.
249
+ * @param {CTS500SamplingRate} samplingRate - Desired A/D sampling rate in Hz.
250
+ * @returns {Promise<void>} A promise that resolves when the command is acknowledged.
251
+ */
252
+ setSamplingRate = async (samplingRate: CTS500SamplingRate): Promise<void> => {
253
+ await this.applyConfigCommand(this.commands.SET_SAMPLING_RATE as number, [
254
+ 0x00,
255
+ 0x00,
256
+ CTS500_SAMPLING_RATE_PARAMS[samplingRate],
257
+ ])
258
+ }
259
+
260
+ /**
261
+ * Retrieves serial number from the device.
262
+ * @returns {Promise<string | undefined>} A Promise that resolves with the serial number.
263
+ */
264
+ serial = async (): Promise<string | undefined> => {
265
+ const hasSerial = this.services
266
+ .find((service) => service.id === "device")
267
+ ?.characteristics.some((characteristic) => characteristic.id === "serial")
268
+
269
+ // MY-BT102 variants can omit the serial characteristic entirely, so guard the read instead of letting it throw.
270
+ if (!hasSerial) {
271
+ return undefined
272
+ }
273
+
274
+ return await this.read("device", "serial", 250)
275
+ }
276
+
277
+ /**
278
+ * Retrieves software version from the device.
279
+ * @returns {Promise<string | undefined>} A Promise that resolves with the software version.
280
+ */
281
+ software = async (): Promise<string | undefined> => {
282
+ return await this.read("device", "software", 250)
283
+ }
284
+
285
+ /**
286
+ * Starts automatic weight uploads.
287
+ * @param {number} [duration=0] - Optional delay before the promise resolves.
288
+ * @returns {Promise<void>} A promise that resolves once upload mode has been enabled.
289
+ */
290
+ stream = async (duration = 0): Promise<void> => {
291
+ this.resetPacketTracking()
292
+ this.isStreaming = true
293
+ const command = this.commands.START_WEIGHT_MEAS as Uint8Array
294
+ await this.queryFrame(
295
+ command,
296
+ (frame) =>
297
+ // The device can start auto-uploading before it echoes the start command, so the first weight frame also confirms success.
298
+ this.isAckFrame(frame, command[1], [command[2], command[3], command[4]]) || this.isWeightFrame(frame),
299
+ )
300
+
301
+ if (duration > 0) {
302
+ await new Promise((resolve) => setTimeout(resolve, duration))
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Stops automatic weight uploads.
308
+ * @returns {Promise<void>} A promise that resolves once upload mode has been disabled.
309
+ */
310
+ stop = async (): Promise<void> => {
311
+ this.isStreaming = false
312
+ const command = this.commands.STOP_WEIGHT_MEAS as Uint8Array
313
+ await this.queryFrame(command, (frame) => this.isAckFrame(frame, command[1], [command[2], command[3], command[4]]))
314
+ }
315
+
316
+ /**
317
+ * Reads the current temperature from the device.
318
+ * @returns {Promise<string | undefined>} A Promise that resolves with the temperature in Celsius.
319
+ */
320
+ temperature = async (): Promise<string | undefined> => {
321
+ const command = this.commands.GET_TEMPERATURE as Uint8Array
322
+ const frame = await this.queryFrame(command, (response) => this.isCommandResponse(response, command[1]))
323
+ if (!frame) {
324
+ return undefined
325
+ }
326
+
327
+ const rawTemperature = frame[5]
328
+ // Negative temperatures are sent as 0x80 + abs(value) instead of two's complement.
329
+ const temperature = rawTemperature >= 0x80 ? -(rawTemperature - 0x80) : rawTemperature
330
+ return temperature.toString()
331
+ }
332
+
333
+ /**
334
+ * Uses the device's hardware tare command when connected and falls back to software tare otherwise.
335
+ * @param {number} [duration=5000] - Software tare duration when the device is not connected.
336
+ * @returns {boolean} `true` when the tare operation started successfully.
337
+ */
338
+ override tare = (duration = 5000): boolean => {
339
+ if (!this.isConnected()) {
340
+ return super.tare(duration)
341
+ }
342
+
343
+ this.updateTimestamp()
344
+ this.clearTareOffset()
345
+ const command = this.commands.TARE_SCALE as Uint8Array
346
+ void this.queryFrame(command, (frame) =>
347
+ this.isAckFrame(frame, command[1], [command[2], command[3], command[4]]),
348
+ ).catch((error: Error) => {
349
+ console.error(error)
350
+ })
351
+ return true
352
+ }
353
+
354
+ /**
355
+ * Reads the current weight from the device in kilograms.
356
+ * @returns {Promise<number | undefined>} A Promise that resolves with the current weight.
357
+ */
358
+ weight = async (): Promise<number | undefined> => {
359
+ const frame = await this.queryFrame(this.commands.GET_WEIGHT, (response) => this.isWeightFrame(response))
360
+ if (!frame) {
361
+ return undefined
362
+ }
363
+
364
+ return (frame[2] * 0x1000000 + frame[3] * 0x10000 + frame[4] * 0x100 + frame[5]) / 100
365
+ }
366
+
367
+ /**
368
+ * Updates the device hardware zero point.
369
+ * @returns {Promise<void>} A promise that resolves when the command is acknowledged.
370
+ */
371
+ zero = async (): Promise<void> => {
372
+ await this.expectAck(this.commands.ZERO_SCALE as number)
373
+ }
374
+
375
+ /**
376
+ * Parses UART frames received over the MY-BT102 notify characteristic.
377
+ * Supports fragmented BLE notifications by buffering until a complete CTS500 frame is available.
378
+ *
379
+ * @param {DataView} value - The notification payload from the device.
380
+ */
381
+ override handleNotifications = (value: DataView): void => {
382
+ this.updateTimestamp()
383
+
384
+ const bytes = new Uint8Array(value.byteLength)
385
+ for (let index = 0; index < value.byteLength; index++) {
386
+ bytes[index] = value.getUint8(index)
387
+ }
388
+
389
+ if (bytes.length === 0) {
390
+ return
391
+ }
392
+
393
+ // BLE notifications can split UART frames arbitrarily, so keep buffering until a full frame validates.
394
+ const combined = new Uint8Array(this.bufferedFrames.length + bytes.length)
395
+ combined.set(this.bufferedFrames)
396
+ combined.set(bytes, this.bufferedFrames.length)
397
+ this.bufferedFrames = combined
398
+
399
+ while (this.bufferedFrames.length >= CTS500_ACK_FRAME_LENGTH) {
400
+ const headerIndex = this.bufferedFrames.indexOf(CTS500_HEADER)
401
+
402
+ if (headerIndex === -1) {
403
+ this.bufferedFrames = new Uint8Array(0)
404
+ return
405
+ }
406
+
407
+ if (headerIndex > 0) {
408
+ this.bufferedFrames = this.bufferedFrames.slice(headerIndex)
409
+ }
410
+
411
+ const frame = this.extractNextFrame()
412
+ if (!frame) {
413
+ return
414
+ }
415
+
416
+ this.bufferedFrames = this.bufferedFrames.slice(frame.length)
417
+ this.handleFrame(frame)
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Waits for a specific frame pattern after sending a CTS500 command.
423
+ */
424
+ private queryFrame = async (
425
+ message: string | Uint8Array | undefined,
426
+ match: (frame: Uint8Array) => boolean,
427
+ ): Promise<Uint8Array | undefined> => {
428
+ return await this.enqueueRequest(async () => {
429
+ const waitForFrame = this.waitForFrame(match)
430
+
431
+ try {
432
+ await this.write("cts500", "tx", message, 0)
433
+ return await waitForFrame
434
+ } catch (error) {
435
+ this.clearPendingFrame(error instanceof Error ? error : new Error(String(error)))
436
+ throw error
437
+ }
438
+ })
439
+ }
440
+
441
+ /**
442
+ * Sends a command that should be acknowledged with a 6-byte echo frame.
443
+ */
444
+ private expectAck = async (
445
+ opcode: number,
446
+ payload: readonly [number, number, number] = [0x00, 0x00, 0x00],
447
+ ): Promise<void> => {
448
+ await this.queryFrame(buildCommand(opcode, payload), (frame) => this.isAckFrame(frame, opcode, payload))
449
+ }
450
+
451
+ /**
452
+ * Sends a configuration command that may reply with either a 6-byte echo, a typed response, or no reply after applying.
453
+ */
454
+ private applyConfigCommand = async (
455
+ opcode: number,
456
+ payload: readonly [number, number, number] = [0x00, 0x00, 0x00],
457
+ ): Promise<void> => {
458
+ try {
459
+ await this.queryFrame(
460
+ buildCommand(opcode, payload),
461
+ (frame) => this.isAckFrame(frame, opcode, payload) || this.isCommandResponse(frame, opcode),
462
+ )
463
+ } catch (error) {
464
+ // Some CTS firmwares apply UART/A-D rate changes immediately and do not echo a matching confirmation frame back over BLE.
465
+ if (error instanceof Error && error.message === "Timed out waiting for CTS500 response") {
466
+ return
467
+ }
468
+
469
+ throw error
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the currently pending frame promise if the incoming frame matches.
475
+ * @returns {boolean} Whether a pending request consumed the frame.
476
+ */
477
+ private consumePendingFrame = (frame: Uint8Array): boolean => {
478
+ if (!this.pendingFrame || !this.pendingFrame.match(frame)) {
479
+ return false
480
+ }
481
+
482
+ const { resolve, timeout } = this.pendingFrame
483
+ clearTimeout(timeout)
484
+ this.pendingFrame = undefined
485
+ resolve(frame)
486
+ return true
487
+ }
488
+
489
+ /**
490
+ * Clears the currently pending frame wait, if any.
491
+ */
492
+ private clearPendingFrame = (error?: Error): void => {
493
+ if (!this.pendingFrame) {
494
+ return
495
+ }
496
+
497
+ const { timeout, reject } = this.pendingFrame
498
+ clearTimeout(timeout)
499
+ this.pendingFrame = undefined
500
+
501
+ if (error) {
502
+ reject(error)
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Extracts the next valid CTS500 frame from the local notification buffer.
508
+ */
509
+ private extractNextFrame = (): Uint8Array | undefined => {
510
+ const secondByte = this.bufferedFrames[1]
511
+
512
+ // 6-byte command echoes and 7-byte data frames share the same header, so prefer command echoes when byte 1 is a known opcode.
513
+ if (this.commandOpcodes.has(secondByte) && this.bufferedFrames.length >= CTS500_ACK_FRAME_LENGTH) {
514
+ const commandCandidate = this.bufferedFrames.slice(0, CTS500_ACK_FRAME_LENGTH)
515
+ if (this.isValidFrame(commandCandidate)) {
516
+ return commandCandidate
517
+ }
518
+ }
519
+
520
+ if (this.bufferedFrames.length >= CTS500_DATA_FRAME_LENGTH) {
521
+ const dataCandidate = this.bufferedFrames.slice(0, CTS500_DATA_FRAME_LENGTH)
522
+ if (this.isValidFrame(dataCandidate)) {
523
+ return dataCandidate
524
+ }
525
+ }
526
+
527
+ if (this.bufferedFrames.length >= CTS500_DATA_FRAME_LENGTH) {
528
+ this.bufferedFrames = this.bufferedFrames.slice(1)
529
+ }
530
+
531
+ return undefined
532
+ }
533
+
534
+ /**
535
+ * Routes a validated CTS500 frame to pending requests, callbacks, and stream processing.
536
+ */
537
+ private handleFrame = (frame: Uint8Array): void => {
538
+ const matchedPendingRequest = this.consumePendingFrame(frame)
539
+
540
+ if (this.isWeightFrame(frame)) {
541
+ // Weight uploads carry a big-endian centi-unit value across bytes 2..5.
542
+ const weight = (frame[2] * 0x1000000 + frame[3] * 0x10000 + frame[4] * 0x100 + frame[5]) / 100
543
+ this.recordWeightMeasurement(weight)
544
+ this.writeCallback(weight.toFixed(2))
545
+ return
546
+ }
547
+
548
+ if (this.isCommandResponse(frame, (this.commands.GET_BATTERY_VOLTAGE as Uint8Array)[1])) {
549
+ const voltage = ((frame[4] << 8) | frame[5]) / 100
550
+ this.writeCallback(voltage.toFixed(2))
551
+ return
552
+ }
553
+
554
+ if (this.isCommandResponse(frame, (this.commands.GET_TEMPERATURE as Uint8Array)[1])) {
555
+ const rawTemperature = frame[5]
556
+ // Negative temperatures are sent as 0x80 + abs(value) instead of two's complement.
557
+ const temperature = rawTemperature >= 0x80 ? -(rawTemperature - 0x80) : rawTemperature
558
+ this.writeCallback(temperature.toString())
559
+ return
560
+ }
561
+
562
+ if (frame.length === CTS500_ACK_FRAME_LENGTH && !matchedPendingRequest) {
563
+ this.writeCallback("OK")
564
+ return
565
+ }
566
+
567
+ if (frame.length === CTS500_DATA_FRAME_LENGTH && !matchedPendingRequest) {
568
+ this.writeCallback(
569
+ Array.from(frame)
570
+ .map((byte) => byte.toString(16).padStart(2, "0").toUpperCase())
571
+ .join(" "),
572
+ )
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Returns whether a frame is a 6-byte command acknowledgment echo for the given opcode.
578
+ */
579
+ private isAckFrame = (frame: Uint8Array, opcode: number, payload: readonly [number, number, number]): boolean => {
580
+ return (
581
+ frame.length === CTS500_ACK_FRAME_LENGTH &&
582
+ frame[0] === CTS500_HEADER &&
583
+ frame[1] === opcode &&
584
+ frame[2] === payload[0] &&
585
+ frame[3] === payload[1] &&
586
+ frame[4] === payload[2] &&
587
+ this.isValidFrame(frame)
588
+ )
589
+ }
590
+
591
+ /**
592
+ * Returns whether a frame is a typed command response (`05 80 <opcode> ... checksum`).
593
+ */
594
+ private isCommandResponse = (frame: Uint8Array, opcode: number): boolean => {
595
+ return (
596
+ frame.length === CTS500_DATA_FRAME_LENGTH &&
597
+ frame[0] === CTS500_HEADER &&
598
+ frame[1] === CTS500_RESPONSE_FLAG &&
599
+ frame[2] === opcode &&
600
+ this.isValidFrame(frame)
601
+ )
602
+ }
603
+
604
+ /**
605
+ * Returns whether a frame contains a weight measurement payload.
606
+ */
607
+ private isWeightFrame = (frame: Uint8Array): boolean => {
608
+ return (
609
+ frame.length === CTS500_DATA_FRAME_LENGTH &&
610
+ frame[0] === CTS500_HEADER &&
611
+ frame[1] !== CTS500_RESPONSE_FLAG &&
612
+ !this.commandOpcodes.has(frame[1]) &&
613
+ this.isValidFrame(frame)
614
+ )
615
+ }
616
+
617
+ /**
618
+ * Updates rolling statistics and emits a force measurement from a CTS500 weight frame.
619
+ */
620
+ private recordWeightMeasurement = (receivedData: number): void => {
621
+ const receivedTime = Date.now()
622
+
623
+ this.currentSamplesPerPacket = 1
624
+ this.recordPacketReceived()
625
+
626
+ const numericData = receivedData - this.applyTare(receivedData)
627
+ const currentMassTotal = Math.max(-1000, numericData)
628
+
629
+ this.peak = Math.max(this.peak, numericData)
630
+ this.min = Math.min(this.min, Math.max(-1000, numericData))
631
+ this.sum += currentMassTotal
632
+ this.dataPointCount++
633
+ this.mean = this.sum / this.dataPointCount
634
+
635
+ this.downloadPackets.push(
636
+ this.buildDownloadPacket(currentMassTotal, [Math.round(receivedData * 100)], {
637
+ timestamp: receivedTime,
638
+ sampleIndex: this.dataPointCount,
639
+ }),
640
+ )
641
+
642
+ if (this.isStreaming) {
643
+ void this.activityCheck(numericData)
644
+ }
645
+
646
+ this.notifyCallback(this.buildForceMeasurement(currentMassTotal))
647
+ }
648
+
649
+ /**
650
+ * Validates a CTS500 frame checksum.
651
+ */
652
+ private isValidFrame = (frame: Uint8Array): boolean => {
653
+ if (frame.length < CTS500_ACK_FRAME_LENGTH || frame[0] !== CTS500_HEADER) {
654
+ return false
655
+ }
656
+
657
+ return calculateChecksum(frame.subarray(0, frame.length - 1)) === frame[frame.length - 1]
658
+ }
659
+
660
+ /**
661
+ * Registers a pending frame matcher with a timeout.
662
+ */
663
+ private waitForFrame = (
664
+ match: (frame: Uint8Array) => boolean,
665
+ timeoutMs = CTS500_RESPONSE_TIMEOUT_MS,
666
+ ): Promise<Uint8Array> => {
667
+ // CTS uses one transparent UART channel for both commands and telemetry, so only one response wait can be active at a time.
668
+ if (this.pendingFrame) {
669
+ throw new Error("CTS500 already has a pending response request")
670
+ }
671
+
672
+ return new Promise<Uint8Array>((resolve, reject) => {
673
+ const timeout = setTimeout(() => {
674
+ if (!this.pendingFrame) {
675
+ return
676
+ }
677
+
678
+ this.pendingFrame = undefined
679
+ reject(new Error("Timed out waiting for CTS500 response"))
680
+ }, timeoutMs)
681
+
682
+ this.pendingFrame = {
683
+ match,
684
+ reject,
685
+ resolve,
686
+ timeout,
687
+ }
688
+ })
689
+ }
690
+
691
+ /**
692
+ * Serializes CTS500 command/response operations so query-style methods can be called in parallel by consumers.
693
+ */
694
+ private enqueueRequest = async <T>(request: () => Promise<T>): Promise<T> => {
695
+ const run = this.requestQueue.then(request, request)
696
+ this.requestQueue = run.then(
697
+ () => undefined,
698
+ () => undefined,
699
+ )
700
+ return await run
701
+ }
702
+ }