@hangtime/grip-connect 0.10.9 → 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 (125) hide show
  1. package/README.md +14 -3
  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 +120 -20
  7. package/dist/cjs/interfaces/command.interface.d.ts.map +1 -1
  8. package/dist/cjs/interfaces/device/climbro.interface.d.ts +25 -0
  9. package/dist/cjs/interfaces/device/climbro.interface.d.ts.map +1 -1
  10. package/dist/cjs/interfaces/device/cts500.interface.d.ts +96 -0
  11. package/dist/cjs/interfaces/device/cts500.interface.d.ts.map +1 -0
  12. package/dist/cjs/interfaces/device/cts500.interface.js +3 -0
  13. package/dist/cjs/interfaces/device/cts500.interface.js.map +1 -0
  14. package/dist/cjs/interfaces/device/forceboard.interface.d.ts +2 -2
  15. package/dist/cjs/interfaces/device/forceboard.interface.d.ts.map +1 -1
  16. package/dist/cjs/interfaces/device/progressor.interface.d.ts +17 -7
  17. package/dist/cjs/interfaces/device/progressor.interface.d.ts.map +1 -1
  18. package/dist/cjs/interfaces/index.d.ts +2 -0
  19. package/dist/cjs/interfaces/index.d.ts.map +1 -1
  20. package/dist/cjs/interfaces/nordic.interface.d.ts +47 -0
  21. package/dist/cjs/interfaces/nordic.interface.d.ts.map +1 -0
  22. package/dist/cjs/interfaces/nordic.interface.js +3 -0
  23. package/dist/cjs/interfaces/nordic.interface.js.map +1 -0
  24. package/dist/cjs/models/device/climbro.model.d.ts +25 -0
  25. package/dist/cjs/models/device/climbro.model.d.ts.map +1 -1
  26. package/dist/cjs/models/device/climbro.model.js +93 -1
  27. package/dist/cjs/models/device/climbro.model.js.map +1 -1
  28. package/dist/cjs/models/device/cts500.model.d.ts +173 -0
  29. package/dist/cjs/models/device/cts500.model.d.ts.map +1 -0
  30. package/dist/cjs/models/device/cts500.model.js +588 -0
  31. package/dist/cjs/models/device/cts500.model.js.map +1 -0
  32. package/dist/cjs/models/device/entralpi.model.d.ts.map +1 -1
  33. package/dist/cjs/models/device/entralpi.model.js +3 -5
  34. package/dist/cjs/models/device/entralpi.model.js.map +1 -1
  35. package/dist/cjs/models/device/forceboard.model.d.ts +2 -2
  36. package/dist/cjs/models/device/forceboard.model.d.ts.map +1 -1
  37. package/dist/cjs/models/device/forceboard.model.js +3 -14
  38. package/dist/cjs/models/device/forceboard.model.js.map +1 -1
  39. package/dist/cjs/models/device/progressor.model.d.ts +25 -12
  40. package/dist/cjs/models/device/progressor.model.d.ts.map +1 -1
  41. package/dist/cjs/models/device/progressor.model.js +82 -31
  42. package/dist/cjs/models/device/progressor.model.js.map +1 -1
  43. package/dist/cjs/models/device.model.d.ts +7 -0
  44. package/dist/cjs/models/device.model.d.ts.map +1 -1
  45. package/dist/cjs/models/device.model.js +52 -32
  46. package/dist/cjs/models/device.model.js.map +1 -1
  47. package/dist/cjs/models/index.d.ts +2 -0
  48. package/dist/cjs/models/index.d.ts.map +1 -1
  49. package/dist/cjs/models/index.js +6 -1
  50. package/dist/cjs/models/index.js.map +1 -1
  51. package/dist/cjs/models/nordic.model.d.ts +128 -0
  52. package/dist/cjs/models/nordic.model.d.ts.map +1 -0
  53. package/dist/cjs/models/nordic.model.js +405 -0
  54. package/dist/cjs/models/nordic.model.js.map +1 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +1 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/interfaces/command.interface.d.ts +120 -20
  60. package/dist/interfaces/command.interface.d.ts.map +1 -1
  61. package/dist/interfaces/device/climbro.interface.d.ts +25 -0
  62. package/dist/interfaces/device/climbro.interface.d.ts.map +1 -1
  63. package/dist/interfaces/device/cts500.interface.d.ts +96 -0
  64. package/dist/interfaces/device/cts500.interface.d.ts.map +1 -0
  65. package/dist/interfaces/device/cts500.interface.js +2 -0
  66. package/dist/interfaces/device/cts500.interface.js.map +1 -0
  67. package/dist/interfaces/device/forceboard.interface.d.ts +2 -2
  68. package/dist/interfaces/device/forceboard.interface.d.ts.map +1 -1
  69. package/dist/interfaces/device/progressor.interface.d.ts +17 -7
  70. package/dist/interfaces/device/progressor.interface.d.ts.map +1 -1
  71. package/dist/interfaces/index.d.ts +2 -0
  72. package/dist/interfaces/index.d.ts.map +1 -1
  73. package/dist/interfaces/nordic.interface.d.ts +47 -0
  74. package/dist/interfaces/nordic.interface.d.ts.map +1 -0
  75. package/dist/interfaces/nordic.interface.js +2 -0
  76. package/dist/interfaces/nordic.interface.js.map +1 -0
  77. package/dist/models/device/climbro.model.d.ts +25 -0
  78. package/dist/models/device/climbro.model.d.ts.map +1 -1
  79. package/dist/models/device/climbro.model.js +93 -1
  80. package/dist/models/device/climbro.model.js.map +1 -1
  81. package/dist/models/device/cts500.model.d.ts +173 -0
  82. package/dist/models/device/cts500.model.d.ts.map +1 -0
  83. package/dist/models/device/cts500.model.js +584 -0
  84. package/dist/models/device/cts500.model.js.map +1 -0
  85. package/dist/models/device/entralpi.model.d.ts.map +1 -1
  86. package/dist/models/device/entralpi.model.js +3 -5
  87. package/dist/models/device/entralpi.model.js.map +1 -1
  88. package/dist/models/device/forceboard.model.d.ts +2 -2
  89. package/dist/models/device/forceboard.model.d.ts.map +1 -1
  90. package/dist/models/device/forceboard.model.js +3 -14
  91. package/dist/models/device/forceboard.model.js.map +1 -1
  92. package/dist/models/device/progressor.model.d.ts +25 -12
  93. package/dist/models/device/progressor.model.d.ts.map +1 -1
  94. package/dist/models/device/progressor.model.js +82 -31
  95. package/dist/models/device/progressor.model.js.map +1 -1
  96. package/dist/models/device.model.d.ts +7 -0
  97. package/dist/models/device.model.d.ts.map +1 -1
  98. package/dist/models/device.model.js +51 -32
  99. package/dist/models/device.model.js.map +1 -1
  100. package/dist/models/index.d.ts +2 -0
  101. package/dist/models/index.d.ts.map +1 -1
  102. package/dist/models/index.js +2 -0
  103. package/dist/models/index.js.map +1 -1
  104. package/dist/models/nordic.model.d.ts +128 -0
  105. package/dist/models/nordic.model.d.ts.map +1 -0
  106. package/dist/models/nordic.model.js +393 -0
  107. package/dist/models/nordic.model.js.map +1 -0
  108. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  109. package/package.json +4 -3
  110. package/src/index.ts +2 -0
  111. package/src/interfaces/command.interface.ts +143 -20
  112. package/src/interfaces/device/climbro.interface.ts +30 -0
  113. package/src/interfaces/device/cts500.interface.ts +113 -0
  114. package/src/interfaces/device/forceboard.interface.ts +2 -2
  115. package/src/interfaces/device/progressor.interface.ts +19 -7
  116. package/src/interfaces/index.ts +4 -0
  117. package/src/interfaces/nordic.interface.ts +47 -0
  118. package/src/models/device/climbro.model.ts +98 -1
  119. package/src/models/device/cts500.model.ts +702 -0
  120. package/src/models/device/entralpi.model.ts +3 -5
  121. package/src/models/device/forceboard.model.ts +3 -14
  122. package/src/models/device/progressor.model.ts +86 -31
  123. package/src/models/device.model.ts +60 -32
  124. package/src/models/index.ts +4 -0
  125. package/src/models/nordic.model.ts +468 -0
@@ -169,11 +169,9 @@ export class Entralpi extends Device implements IEntralpi {
169
169
  const receivedData: string = (value.getUint16(0) / 100).toFixed(1)
170
170
 
171
171
  const convertedData = Number(receivedData)
172
- // Adjust weight by using the tare value
173
- // If tare is 0, use the original weight, otherwise subtract tare and invert.
174
- // This will display the removed or 'no-hanging' weight.
175
- const tare = this.applyTare(convertedData)
176
- const numericData = tare === 0 ? convertedData : (convertedData - tare) * -1
172
+ // Adjust weight by using the tare value.
173
+ // Keep stream output consistent with other devices: positive load after tare.
174
+ const numericData = convertedData - this.applyTare(convertedData)
177
175
  const currentMassTotal = Math.max(-1000, numericData)
178
176
 
179
177
  // Update session stats before building packet
@@ -1,11 +1,11 @@
1
- import { Device } from "../device.model.js"
1
+ import { NordicDfuDevice, createNordicDfuService } from "../nordic.model.js"
2
2
  import type { IForceBoard } from "../../interfaces/device/forceboard.interface.js"
3
3
 
4
4
  /**
5
5
  * Represents a PitchSix Force Board device.
6
6
  * {@link https://pitchsix.com}
7
7
  */
8
- export class ForceBoard extends Device implements IForceBoard {
8
+ export class ForceBoard extends NordicDfuDevice implements IForceBoard {
9
9
  protected override streamUnit = "lbs" as const
10
10
 
11
11
  constructor() {
@@ -46,18 +46,7 @@ export class ForceBoard extends Device implements IForceBoard {
46
46
  },
47
47
  ],
48
48
  },
49
- {
50
- name: "Nordic Device Firmware Update (DFU) Service",
51
- id: "dfu",
52
- uuid: "0000fe59-0000-1000-8000-00805f9b34fb",
53
- characteristics: [
54
- {
55
- name: "Buttonless DFU",
56
- id: "dfu",
57
- uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
58
- },
59
- ],
60
- },
49
+ createNordicDfuService(),
61
50
  {
62
51
  name: "",
63
52
  id: "",
@@ -1,4 +1,4 @@
1
- import { Device } from "../device.model.js"
1
+ import { NordicDfuDevice, createNordicDfuService } from "../nordic.model.js"
2
2
  import type { IProgressor } from "../../interfaces/device/progressor.interface.js"
3
3
 
4
4
  /**
@@ -66,9 +66,8 @@ function parseProgressorIdPayload(payload: Uint8Array): string {
66
66
  }
67
67
 
68
68
  /**
69
- * Parse calibration curve: 12 opaque bytes (CalibrationCurve = [u8; 12]).
70
- * Progressor two-point calibration stores two raw ADC readings (zero + reference)
71
- * and a third value (version/reserved). Exact layout is device-specific.
69
+ * Parse calibration block: float32 LE.
70
+ * value = raw * slope + intercept + trim.
72
71
  */
73
72
  function parseCalibrationCurvePayload(payload: Uint8Array): string {
74
73
  const hex = toHex(payload)
@@ -76,14 +75,49 @@ function parseCalibrationCurvePayload(payload: Uint8Array): string {
76
75
  if (payload.length !== 12) return hex
77
76
 
78
77
  const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
79
- const [v0, v1, v2] = [0, 4, 8].map((o) => view.getUint32(o, true))
78
+ const slope = view.getFloat32(0, true)
79
+ const intercept = view.getFloat32(4, true)
80
+ const trim = view.getFloat32(8, true)
81
+ const effectiveOffset = intercept + trim
82
+
83
+ const formatSignedFloat = (value: number): string => {
84
+ const formatted = formatCalibrationFloat(Math.abs(value))
85
+ return value < 0 ? ` - ${formatted}` : ` + ${formatted}`
86
+ }
87
+
88
+ return `${hex} — slope: ${formatCalibrationFloat(slope)} | intercept: ${formatCalibrationFloat(intercept)} | trim: ${formatCalibrationFloat(trim)} | effective offset: ${formatCalibrationFloat(effectiveOffset)} | formula: raw * ${formatCalibrationFloat(slope)}${formatSignedFloat(intercept)}${formatSignedFloat(trim)}`
89
+ }
80
90
 
81
- return `${hex} — 1: ${v1.toLocaleString()} | 2: ${v0.toLocaleString()} | 3: ${v2}`
91
+ /**
92
+ * Format floating-point values for calibration-table display.
93
+ */
94
+ function formatCalibrationFloat(value: number): string {
95
+ if (!Number.isFinite(value)) return String(value)
96
+ const abs = Math.abs(value)
97
+ return abs !== 0 && (abs >= 1_000_000 || abs < 0.0001) ? value.toExponential(6) : value.toFixed(6)
82
98
  }
83
99
 
84
- export class Progressor extends Device implements IProgressor {
100
+ /**
101
+ * Parse one calibration table record: [u32 lower, u32 upper, f32 slope, f32 intercept].
102
+ */
103
+ function parseCalibrationTableRecordPayload(payload: Uint8Array, index: number): string {
104
+ if (payload.length !== 16) return `${String(index).padStart(2, "0")}: ${toHex(payload)}`
105
+
106
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
107
+ const lowerRaw = view.getUint32(0, true)
108
+ const upperRaw = view.getUint32(4, true)
109
+ const slope = view.getFloat32(8, true)
110
+ const intercept = view.getFloat32(12, true)
111
+ const hex = toHex(payload)
112
+
113
+ return `${String(index).padStart(2, "0")}: ${hex} | raw ${lowerRaw.toLocaleString()}..${upperRaw.toLocaleString()} | slope ${formatCalibrationFloat(slope)} | intercept ${formatCalibrationFloat(intercept)}`
114
+ }
115
+
116
+ export class Progressor extends NordicDfuDevice implements IProgressor {
85
117
  /** Device timestamps (µs) of recent samples (samples in last 1s device time). */
86
118
  private recentSampleTimestamps: number[] = []
119
+ /** 1-based index for multi-packet calibration-table export responses. */
120
+ private calibrationTableRecordIndex = 0
87
121
 
88
122
  constructor() {
89
123
  super({
@@ -106,20 +140,9 @@ export class Progressor extends Device implements IProgressor {
106
140
  },
107
141
  ],
108
142
  },
109
- {
110
- name: "Nordic Device Firmware Update (DFU) Service",
111
- id: "dfu",
112
- uuid: "0000fe59-0000-1000-8000-00805f9b34fb",
113
- characteristics: [
114
- {
115
- name: "Buttonless DFU",
116
- id: "dfu",
117
- uuid: "8ec90003-f315-4f60-9fb8-838830daea50",
118
- },
119
- ],
120
- },
143
+ createNordicDfuService(),
121
144
  ],
122
- // Tindeq API: opcode = single byte (ASCII char code = decimal 100–114;)
145
+ // Tindeq API: opcode = single byte (ASCII char code = decimal 100–114 v2 firmware: 115-118)
123
146
  commands: {
124
147
  TARE_SCALE: "d", // 100 (0x64)
125
148
  START_WEIGHT_MEAS: "e", // 101 (0x65)
@@ -136,6 +159,11 @@ export class Progressor extends Device implements IProgressor {
136
159
  GET_PROGRESSOR_ID: "p", // 112 (0x70)
137
160
  SET_CALIBRATION: "q", // 113 (0x71)
138
161
  GET_CALIBRATION: "r", // 114 (0x72)
162
+ // V2 FIRMWARE ONLY COMMANDS
163
+ // ADD_CALIBRATION_TABLE_POINT: "s", // 115 (0x73)
164
+ GET_CALIBRATION_TABLE: "t", // 116 (0x74)
165
+ REBOOT: "u", // 117 (0x75)
166
+ // CLR_CALIBRATION_TABLE: "v", // 118 (0x76)
139
167
  },
140
168
  })
141
169
  }
@@ -153,7 +181,7 @@ export class Progressor extends Device implements IProgressor {
153
181
  }
154
182
 
155
183
  /**
156
- * Retrieves firmware version from the device (GetAppVersion, opcode 0x6B).
184
+ * Retrieves firmware version from the device.
157
185
  * @returns {Promise<string>} A Promise that resolves with the firmware version,
158
186
  */
159
187
  firmware = async (): Promise<string | undefined> => {
@@ -165,7 +193,7 @@ export class Progressor extends Device implements IProgressor {
165
193
  }
166
194
 
167
195
  /**
168
- * Retrieves the Progressor ID from the device (opcode 0x70).
196
+ * Retrieves the Progressor ID from the device.
169
197
  * @returns {Promise<string>} A Promise that resolves with the raw response (hex of payload).
170
198
  */
171
199
  progressorId = async (): Promise<string | undefined> => {
@@ -177,8 +205,8 @@ export class Progressor extends Device implements IProgressor {
177
205
  }
178
206
 
179
207
  /**
180
- * Retrieves calibration values from the device.
181
- * @returns {Promise<string>} A Promise that resolves with the raw response (hex of payload).
208
+ * Retrieves the linear calibration block from the device.
209
+ * Returns raw hex plus decoded slope/intercept/trim coefficients.
182
210
  */
183
211
  calibration = async (): Promise<string | undefined> => {
184
212
  let response: string | undefined = undefined
@@ -189,7 +217,21 @@ export class Progressor extends Device implements IProgressor {
189
217
  }
190
218
 
191
219
  /**
192
- * Computes calibration curve from stored points and saves to flash (opcode 0x6A).
220
+ * Retrieves the hidden 15-entry piecewise calibration table.
221
+ * Each response packet contains one 16-byte record.
222
+ * @returns {Promise<string | undefined>} Newline-separated decoded records.
223
+ */
224
+ calibrationTable = async (): Promise<string | undefined> => {
225
+ const responses: string[] = []
226
+ this.calibrationTableRecordIndex = 0
227
+ await this.write("progressor", "tx", this.commands.GET_CALIBRATION_TABLE, 1000, (data) => {
228
+ responses.push(data)
229
+ })
230
+ return responses.length > 0 ? responses.join("\n") : undefined
231
+ }
232
+
233
+ /**
234
+ * Computes calibration curve from stored points and saves to flash.
193
235
  * Requires addCalibrationPoint() for zero and reference. Normal flow: i → i → j.
194
236
  * @returns {Promise<void>} A Promise that resolves when the command is sent.
195
237
  */
@@ -198,24 +240,24 @@ export class Progressor extends Device implements IProgressor {
198
240
  }
199
241
 
200
242
  /**
201
- * Opcode 0x71 ('q'): write calibration curve directly (raw overwrite).
243
+ * Write calibration block directly (raw overwrite).
202
244
  *
203
245
  * Payload layout (14 bytes):
204
- * - [0] opcode ('q' = 0x71)
246
+ * - [0] opcode ('q')
205
247
  * - [1] reserved (ignored by firmware)
206
- * - [2..13] 12-byte calibration curve (3× u32 LE read at offsets +2, +6, +10)
248
+ * - [2..13] 12-byte calibration block (3× float32 LE: slope, intercept, trim)
207
249
  *
208
250
  * Notes:
209
251
  * - This command does not compute anything; it overwrites stored calibration data.
210
- * - Sending only the opcode (no 12-byte curve) is not a supported "reset" mode.
252
+ * - Sending only the opcode (no 12-byte calibration block) is not a supported "reset" mode.
211
253
  *
212
- * @param curve - Raw 12-byte calibration curve (required).
254
+ * @param curve - Raw 12-byte calibration block (3× float32 LE: slope, intercept, trim) (required).
213
255
  * @returns Promise that resolves when the command is sent.
214
256
  */
215
257
  setCalibration = async (curve: Uint8Array): Promise<void> => {
216
258
  if (curve.length !== 12) throw new Error("Curve must be 12 bytes")
217
259
 
218
- const opcode = (this.commands.SET_CALIBRATION as string).charCodeAt(0) // 0x71
260
+ const opcode = (this.commands.SET_CALIBRATION as string).charCodeAt(0)
219
261
  const payload = new Uint8Array(14)
220
262
 
221
263
  payload[0] = opcode
@@ -269,6 +311,16 @@ export class Progressor extends Device implements IProgressor {
269
311
  await this.write("progressor", "tx", typeof cmd === "string" ? cmd : String(cmd), 0)
270
312
  }
271
313
 
314
+ /**
315
+ * Reboots the device immediately.
316
+ * @returns {Promise<void>} A Promise that resolves when the command is sent.
317
+ */
318
+ reboot = async (): Promise<void> => {
319
+ const opcode = (this.commands.REBOOT as string).charCodeAt(0)
320
+ // Send byte 1 to trigger the reboot.
321
+ await this.write("progressor", "tx", new Uint8Array([opcode, 0, 1]), 0)
322
+ }
323
+
272
324
  /**
273
325
  * Retrieves error information from the device.
274
326
  * @returns {Promise<string | undefined>} A Promise that resolves with the error info text.
@@ -364,6 +416,9 @@ export class Progressor extends Device implements IProgressor {
364
416
  output = parseProgressorIdPayload(payload)
365
417
  } else if (this.writeLast === this.commands.GET_CALIBRATION) {
366
418
  output = parseCalibrationCurvePayload(payload)
419
+ } else if (this.writeLast === this.commands.GET_CALIBRATION_TABLE) {
420
+ this.calibrationTableRecordIndex += 1
421
+ output = parseCalibrationTableRecordPayload(payload, this.calibrationTableRecordIndex)
367
422
  } else {
368
423
  // Unknown command response: return raw hex
369
424
  output = toHex(payload)
@@ -213,6 +213,14 @@ export abstract class Device extends BaseModel implements IDevice {
213
213
  */
214
214
  private tareActive = false
215
215
 
216
+ /**
217
+ * The characteristic ID that accepts notifications.
218
+ * Switching to "buttonless" allows to enter DFU mode and update the firmware.
219
+ *
220
+ * @type {"rx" | "buttonless" | "control"}
221
+ */
222
+ protected notifyCharacteristicId: "rx" | "buttonless" = "rx"
223
+
216
224
  /**
217
225
  * Timestamp when the tare calibration process started.
218
226
  * @type {number | null}
@@ -562,10 +570,12 @@ export abstract class Device extends BaseModel implements IDevice {
562
570
  // }
563
571
  // }
564
572
 
565
- this.bluetooth = await bluetooth.requestDevice({
566
- filters: this.filters,
567
- optionalServices: deviceServices,
568
- })
573
+ if (!this.bluetooth?.gatt) {
574
+ this.bluetooth = await bluetooth.requestDevice({
575
+ filters: this.filters,
576
+ optionalServices: deviceServices,
577
+ })
578
+ }
569
579
 
570
580
  if (!this.bluetooth.gatt) {
571
581
  throw new Error("GATT is not available on this device")
@@ -573,7 +583,7 @@ export abstract class Device extends BaseModel implements IDevice {
573
583
 
574
584
  this.bluetooth.addEventListener("gattserverdisconnected", this.onDisconnectedListener)
575
585
 
576
- this.server = await this.bluetooth.gatt.connect()
586
+ this.server = this.bluetooth.gatt.connected ? this.bluetooth.gatt : await this.bluetooth.gatt.connect()
577
587
 
578
588
  if (this.server.connected) {
579
589
  await this.onConnected(onSuccess)
@@ -596,31 +606,39 @@ export abstract class Device extends BaseModel implements IDevice {
596
606
  * device.disconnect();
597
607
  */
598
608
  disconnect = (): void => {
599
- if (this.isConnected()) {
609
+ const isConnected = this.isConnected()
610
+ if (isConnected) {
600
611
  this.updateTimestamp()
601
- // Remove all notification listeners
602
- this.services.forEach((service) => {
603
- service.characteristics.forEach((char) => {
604
- // Look for the "rx" characteristic that accepts notifications
605
- if (char.characteristic && char.id === "rx") {
606
- char.characteristic.stopNotifications()
607
- const listener = this.notificationListeners.get(char.uuid)
608
- if (listener) {
609
- char.characteristic.removeEventListener("characteristicvaluechanged", listener)
610
- this.notificationListeners.delete(char.uuid)
611
- }
612
- }
613
- })
612
+ }
613
+
614
+ // Remove all notification listeners and stop notifications if possible.
615
+ this.services.forEach((service) => {
616
+ service.characteristics.forEach((char) => {
617
+ if (!char.characteristic || char.id !== this.notifyCharacteristicId) return
618
+
619
+ if (isConnected) {
620
+ // Best effort only: avoid unhandled rejections when the device already disconnected.
621
+ void char.characteristic.stopNotifications().catch(() => undefined)
622
+ }
623
+
624
+ const listener = this.notificationListeners.get(char.uuid)
625
+ if (listener) {
626
+ char.characteristic.removeEventListener("characteristicvaluechanged", listener)
627
+ this.notificationListeners.delete(char.uuid)
628
+ }
614
629
  })
615
- // Remove disconnect listener
616
- this.bluetooth?.removeEventListener("gattserverdisconnected", this.onDisconnectedListener)
617
- // Safely attempt to disconnect the device's GATT server, if available
618
- this.bluetooth?.gatt?.disconnect()
619
- // Reset properties
620
- this.server = undefined
621
- this.writeLast = null
622
- this.isActive = false
630
+ })
631
+
632
+ // Remove disconnect listener
633
+ this.bluetooth?.removeEventListener("gattserverdisconnected", this.onDisconnectedListener)
634
+ // Safely attempt to disconnect the device's GATT server, if available
635
+ if (this.bluetooth?.gatt?.connected) {
636
+ this.bluetooth.gatt.disconnect()
623
637
  }
638
+ // Reset properties
639
+ this.server = undefined
640
+ this.writeLast = null
641
+ this.isActive = false
624
642
  }
625
643
 
626
644
  /**
@@ -890,7 +908,9 @@ export abstract class Device extends BaseModel implements IDevice {
890
908
  }
891
909
 
892
910
  for (const service of services) {
893
- const matchingService = this.services.find((boardService) => boardService.uuid === service.uuid)
911
+ const matchingService = this.services.find(
912
+ (boardService) => boardService.uuid.toLowerCase() === service.uuid.toLowerCase(),
913
+ )
894
914
 
895
915
  if (matchingService) {
896
916
  // Android bug: Add a small delay before getting characteristics
@@ -899,16 +919,20 @@ export abstract class Device extends BaseModel implements IDevice {
899
919
  const characteristics = await service.getCharacteristics()
900
920
 
901
921
  for (const characteristic of matchingService.characteristics) {
902
- const matchingCharacteristic = characteristics.find((char) => char.uuid === characteristic.uuid)
922
+ const matchingCharacteristic = characteristics.find(
923
+ (char) => char.uuid.toLowerCase() === characteristic.uuid.toLowerCase(),
924
+ )
903
925
 
904
926
  if (matchingCharacteristic) {
905
927
  // Find the corresponding characteristic descriptor in the service's characteristics array
906
- const descriptor = matchingService.characteristics.find((char) => char.uuid === matchingCharacteristic.uuid)
928
+ const descriptor = matchingService.characteristics.find(
929
+ (char) => char.uuid.toLowerCase() === matchingCharacteristic.uuid.toLowerCase(),
930
+ )
907
931
  if (descriptor) {
908
932
  // Assign the actual Bluetooth characteristic object to the descriptor so it can be used later
909
933
  descriptor.characteristic = matchingCharacteristic
910
- // Look for the "rx" characteristic id that accepts notifications
911
- if (descriptor.id === "rx") {
934
+ // Look for our default notify characteristic id that accepts notifications
935
+ if (descriptor.id === this.notifyCharacteristicId) {
912
936
  // Start receiving notifications for changes on this characteristic
913
937
  matchingCharacteristic.startNotifications()
914
938
  // Triggered when the characteristic's value changes
@@ -926,6 +950,10 @@ export abstract class Device extends BaseModel implements IDevice {
926
950
  this.notificationListeners.set(descriptor.uuid, listener)
927
951
  }
928
952
  }
953
+ } else if (matchingService.id === "dfu") {
954
+ // App mode exposes buttonless only, bootloader exposes control+packet only.
955
+ delete characteristic.characteristic
956
+ continue
929
957
  } else {
930
958
  throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`)
931
959
  }
@@ -1,9 +1,13 @@
1
1
  export { Climbro } from "./device/climbro.model.js"
2
2
 
3
+ export { CTS500 } from "./device/cts500.model.js"
4
+
3
5
  export { Entralpi } from "./device/entralpi.model.js"
4
6
 
5
7
  export { ForceBoard } from "./device/forceboard.model.js"
6
8
 
9
+ export { NordicDfuDevice, createNordicDfuService } from "./nordic.model.js"
10
+
7
11
  export { KilterBoard, KilterBoardPlacementRoles } from "./device/kilterboard.model.js"
8
12
 
9
13
  export { Motherboard } from "./device/motherboard.model.js"