@robotical/raftjs 2.1.0 → 2.1.3

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 (172) hide show
  1. package/devdocs/devbin-backwards-compatibility.md +105 -0
  2. package/devdocs/pseudocode-to-js-transpiler.md +563 -0
  3. package/dist/react-native/PseudocodeTranspiler.d.ts +6 -0
  4. package/dist/react-native/PseudocodeTranspiler.js +115 -0
  5. package/dist/react-native/PseudocodeTranspiler.js.map +1 -0
  6. package/dist/react-native/RaftAttributeHandler.d.ts +1 -1
  7. package/dist/react-native/RaftAttributeHandler.js +108 -32
  8. package/dist/react-native/RaftAttributeHandler.js.map +1 -1
  9. package/dist/react-native/RaftChannelBLE.web.d.ts +4 -0
  10. package/dist/react-native/RaftChannelBLE.web.js +59 -21
  11. package/dist/react-native/RaftChannelBLE.web.js.map +1 -1
  12. package/dist/react-native/RaftChannelSimulated.d.ts +1 -0
  13. package/dist/react-native/RaftChannelSimulated.js +9 -5
  14. package/dist/react-native/RaftChannelSimulated.js.map +1 -1
  15. package/dist/react-native/RaftChannelWebSocket.js +16 -1
  16. package/dist/react-native/RaftChannelWebSocket.js.map +1 -1
  17. package/dist/react-native/RaftConnector.d.ts +29 -1
  18. package/dist/react-native/RaftConnector.js +177 -11
  19. package/dist/react-native/RaftConnector.js.map +1 -1
  20. package/dist/react-native/RaftCustomAttrHandler.d.ts +2 -2
  21. package/dist/react-native/RaftCustomAttrHandler.js +32 -44
  22. package/dist/react-native/RaftCustomAttrHandler.js.map +1 -1
  23. package/dist/react-native/RaftDeviceInfo.d.ts +18 -0
  24. package/dist/react-native/RaftDeviceInfo.js +8 -0
  25. package/dist/react-native/RaftDeviceInfo.js.map +1 -1
  26. package/dist/react-native/RaftDeviceManager.d.ts +30 -3
  27. package/dist/react-native/RaftDeviceManager.js +618 -107
  28. package/dist/react-native/RaftDeviceManager.js.map +1 -1
  29. package/dist/react-native/RaftDeviceMgrIF.d.ts +11 -2
  30. package/dist/react-native/RaftDeviceStates.d.ts +27 -3
  31. package/dist/react-native/RaftDeviceStates.js +31 -6
  32. package/dist/react-native/RaftDeviceStates.js.map +1 -1
  33. package/dist/react-native/RaftFileHandler.d.ts +1 -1
  34. package/dist/react-native/RaftFileHandler.js +101 -34
  35. package/dist/react-native/RaftFileHandler.js.map +1 -1
  36. package/dist/react-native/RaftMicroPythonConsoleClient.d.ts +38 -0
  37. package/dist/react-native/RaftMicroPythonConsoleClient.js +45 -0
  38. package/dist/react-native/RaftMicroPythonConsoleClient.js.map +1 -0
  39. package/dist/react-native/RaftMsgHandler.d.ts +1 -1
  40. package/dist/react-native/RaftMsgHandler.js +6 -3
  41. package/dist/react-native/RaftMsgHandler.js.map +1 -1
  42. package/dist/react-native/RaftPublish.d.ts +2 -0
  43. package/dist/react-native/RaftPublish.js +81 -0
  44. package/dist/react-native/RaftPublish.js.map +1 -0
  45. package/dist/react-native/RaftStreamHandler.d.ts +11 -0
  46. package/dist/react-native/RaftStreamHandler.js +66 -0
  47. package/dist/react-native/RaftStreamHandler.js.map +1 -1
  48. package/dist/react-native/RaftStruct.d.ts +2 -2
  49. package/dist/react-native/RaftStruct.js +97 -26
  50. package/dist/react-native/RaftStruct.js.map +1 -1
  51. package/dist/react-native/RaftSystemType.d.ts +1 -0
  52. package/dist/react-native/RaftSystemUtils.d.ts +17 -1
  53. package/dist/react-native/RaftSystemUtils.js +51 -0
  54. package/dist/react-native/RaftSystemUtils.js.map +1 -1
  55. package/dist/react-native/RaftTimezone.d.ts +16 -0
  56. package/dist/react-native/RaftTimezone.js +153 -0
  57. package/dist/react-native/RaftTimezone.js.map +1 -0
  58. package/dist/react-native/RaftTypes.d.ts +46 -1
  59. package/dist/react-native/RaftTypes.js.map +1 -1
  60. package/dist/react-native/RaftUpdateManager.js +1 -1
  61. package/dist/react-native/RaftUpdateManager.js.map +1 -1
  62. package/dist/react-native/main.d.ts +3 -0
  63. package/dist/react-native/main.js +8 -1
  64. package/dist/react-native/main.js.map +1 -1
  65. package/dist/web/PseudocodeTranspiler.d.ts +6 -0
  66. package/dist/web/PseudocodeTranspiler.js +115 -0
  67. package/dist/web/PseudocodeTranspiler.js.map +1 -0
  68. package/dist/web/RaftAttributeHandler.d.ts +1 -1
  69. package/dist/web/RaftAttributeHandler.js +108 -32
  70. package/dist/web/RaftAttributeHandler.js.map +1 -1
  71. package/dist/web/RaftChannelBLE.web.d.ts +4 -0
  72. package/dist/web/RaftChannelBLE.web.js +59 -21
  73. package/dist/web/RaftChannelBLE.web.js.map +1 -1
  74. package/dist/web/RaftChannelSimulated.d.ts +1 -0
  75. package/dist/web/RaftChannelSimulated.js +9 -5
  76. package/dist/web/RaftChannelSimulated.js.map +1 -1
  77. package/dist/web/RaftChannelWebSocket.js +16 -1
  78. package/dist/web/RaftChannelWebSocket.js.map +1 -1
  79. package/dist/web/RaftConnector.d.ts +29 -1
  80. package/dist/web/RaftConnector.js +177 -11
  81. package/dist/web/RaftConnector.js.map +1 -1
  82. package/dist/web/RaftCustomAttrHandler.d.ts +2 -2
  83. package/dist/web/RaftCustomAttrHandler.js +32 -44
  84. package/dist/web/RaftCustomAttrHandler.js.map +1 -1
  85. package/dist/web/RaftDeviceInfo.d.ts +18 -0
  86. package/dist/web/RaftDeviceInfo.js +8 -0
  87. package/dist/web/RaftDeviceInfo.js.map +1 -1
  88. package/dist/web/RaftDeviceManager.d.ts +30 -3
  89. package/dist/web/RaftDeviceManager.js +618 -107
  90. package/dist/web/RaftDeviceManager.js.map +1 -1
  91. package/dist/web/RaftDeviceMgrIF.d.ts +11 -2
  92. package/dist/web/RaftDeviceStates.d.ts +27 -3
  93. package/dist/web/RaftDeviceStates.js +31 -6
  94. package/dist/web/RaftDeviceStates.js.map +1 -1
  95. package/dist/web/RaftFileHandler.d.ts +1 -1
  96. package/dist/web/RaftFileHandler.js +101 -34
  97. package/dist/web/RaftFileHandler.js.map +1 -1
  98. package/dist/web/RaftMicroPythonConsoleClient.d.ts +38 -0
  99. package/dist/web/RaftMicroPythonConsoleClient.js +45 -0
  100. package/dist/web/RaftMicroPythonConsoleClient.js.map +1 -0
  101. package/dist/web/RaftMsgHandler.d.ts +1 -1
  102. package/dist/web/RaftMsgHandler.js +6 -3
  103. package/dist/web/RaftMsgHandler.js.map +1 -1
  104. package/dist/web/RaftPublish.d.ts +2 -0
  105. package/dist/web/RaftPublish.js +81 -0
  106. package/dist/web/RaftPublish.js.map +1 -0
  107. package/dist/web/RaftStreamHandler.d.ts +11 -0
  108. package/dist/web/RaftStreamHandler.js +66 -0
  109. package/dist/web/RaftStreamHandler.js.map +1 -1
  110. package/dist/web/RaftStruct.d.ts +2 -2
  111. package/dist/web/RaftStruct.js +97 -26
  112. package/dist/web/RaftStruct.js.map +1 -1
  113. package/dist/web/RaftSystemType.d.ts +1 -0
  114. package/dist/web/RaftSystemUtils.d.ts +17 -1
  115. package/dist/web/RaftSystemUtils.js +51 -0
  116. package/dist/web/RaftSystemUtils.js.map +1 -1
  117. package/dist/web/RaftTimezone.d.ts +16 -0
  118. package/dist/web/RaftTimezone.js +153 -0
  119. package/dist/web/RaftTimezone.js.map +1 -0
  120. package/dist/web/RaftTypes.d.ts +46 -1
  121. package/dist/web/RaftTypes.js.map +1 -1
  122. package/dist/web/RaftUpdateManager.js +1 -1
  123. package/dist/web/RaftUpdateManager.js.map +1 -1
  124. package/dist/web/main.d.ts +3 -0
  125. package/dist/web/main.js +8 -1
  126. package/dist/web/main.js.map +1 -1
  127. package/examples/dashboard/package.json +2 -2
  128. package/examples/dashboard/src/DeviceActionsForm.tsx +177 -17
  129. package/examples/dashboard/src/DeviceLineChart.tsx +16 -3
  130. package/examples/dashboard/src/DevicePanel.tsx +92 -11
  131. package/examples/dashboard/src/DeviceSelectDialog.tsx +224 -0
  132. package/examples/dashboard/src/DeviceStatsPanel.tsx +76 -0
  133. package/examples/dashboard/src/DevicesPanel.tsx +11 -0
  134. package/examples/dashboard/src/LogConfigPanel.tsx +357 -0
  135. package/examples/dashboard/src/LogFilesPanel.tsx +200 -0
  136. package/examples/dashboard/src/LoggingPanel.tsx +264 -0
  137. package/examples/dashboard/src/Main.tsx +12 -2
  138. package/examples/dashboard/src/SettingsScreen.tsx +9 -4
  139. package/examples/dashboard/src/SystemTypeCog/CogStateInfo.ts +10 -3
  140. package/examples/dashboard/src/SystemTypeCog/SystemTypeCog.ts +37 -3
  141. package/examples/dashboard/src/SystemTypeGeneric/StateInfoGeneric.ts +10 -2
  142. package/examples/dashboard/src/SystemTypeGeneric/SystemTypeGeneric.ts +41 -7
  143. package/examples/dashboard/src/SystemTypeMarty/RICStateInfo.ts +34 -3
  144. package/examples/dashboard/src/styles.css +766 -1
  145. package/notes/web-ble-reconnect-retry.md +69 -0
  146. package/package.json +10 -7
  147. package/src/PseudocodeTranspiler.test.ts +372 -0
  148. package/src/PseudocodeTranspiler.ts +127 -0
  149. package/src/RaftAttributeHandler.ts +152 -76
  150. package/src/RaftChannelBLE.web.ts +62 -20
  151. package/src/RaftChannelSimulated.ts +10 -5
  152. package/src/RaftChannelWebSocket.ts +16 -2
  153. package/src/RaftConnector.ts +204 -17
  154. package/src/RaftCustomAttrHandler.ts +35 -45
  155. package/src/RaftDeviceInfo.ts +27 -0
  156. package/src/RaftDeviceManager.test.ts +164 -0
  157. package/src/RaftDeviceManager.ts +705 -127
  158. package/src/RaftDeviceMgrIF.ts +13 -2
  159. package/src/RaftDeviceStates.ts +49 -8
  160. package/src/RaftFileHandler.ts +112 -39
  161. package/src/RaftMicroPythonConsoleClient.ts +78 -0
  162. package/src/RaftMsgHandler.ts +8 -4
  163. package/src/RaftPublish.ts +92 -0
  164. package/src/RaftStreamHandler.ts +84 -1
  165. package/src/RaftStruct.test.ts +229 -0
  166. package/src/RaftStruct.ts +101 -37
  167. package/src/RaftSystemType.ts +1 -0
  168. package/src/RaftSystemUtils.ts +59 -0
  169. package/src/RaftTimezone.ts +151 -0
  170. package/src/RaftTypes.ts +57 -1
  171. package/src/RaftUpdateManager.ts +1 -1
  172. package/src/main.ts +3 -0
@@ -20,15 +20,16 @@ export default class AttributeHandler {
20
20
  // Message timestamp size
21
21
  private POLL_RESULT_TIMESTAMP_SIZE = 2;
22
22
  private POLL_RESULT_WRAP_VALUE = this.POLL_RESULT_TIMESTAMP_SIZE === 2 ? 65536 : 4294967296;
23
- private POLL_RESULT_RESOLUTION_US = 1000;
24
-
25
- public processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata,
26
- devAttrsState: DeviceAttributesState, maxDataPoints: number): number {
27
-
23
+ private POLL_RESULT_RESOLUTION_US = 100;
24
+
25
+ public processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata,
26
+ devAttrsState: DeviceAttributesState, maxDataPoints: number, msgEndIdx = msgBuffer.length): number {
27
+
28
28
  // console.log(`processMsgAttrGroup msg ${msgHexStr} timestamp ${timestamp} origTimestamp ${origTimestamp} msgBufIdx ${msgBufIdx}`)
29
+ const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length);
29
30
 
30
31
  // Extract msg timestamp
31
- const { newBufIdx, timestampUs } = this.extractTimestampAndAdvanceIdx(msgBuffer, msgBufIdx, deviceTimeline);
32
+ const { newBufIdx, timestampUs } = this.extractTimestampAndAdvanceIdx(msgBuffer, msgBufIdx, deviceTimeline, boundedMsgEndIdx);
32
33
  if (newBufIdx < 0)
33
34
  return -1;
34
35
  msgBufIdx = newBufIdx;
@@ -37,11 +38,38 @@ export default class AttributeHandler {
37
38
  const msgDataStartIdx = msgBufIdx;
38
39
 
39
40
  // New attribute values (in order as they appear in the attributes JSON)
40
- let newAttrValues: number[][] = [];
41
+ let newAttrValues: (number | string)[][] = [];
41
42
  if ("c" in pollRespMetadata) {
42
43
 
43
44
  // Extract attribute values using custom handler
44
- newAttrValues = this._customAttrHandler.handleAttr(pollRespMetadata, msgBuffer, msgBufIdx);
45
+ newAttrValues = this._customAttrHandler.handleAttr(pollRespMetadata, msgBuffer, msgBufIdx, boundedMsgEndIdx);
46
+
47
+ // Apply per-attribute transforms that the custom handler doesn't handle
48
+ for (let attrIdx = 0; attrIdx < pollRespMetadata.a.length && attrIdx < newAttrValues.length; attrIdx++) {
49
+ const attrDef = pollRespMetadata.a[attrIdx];
50
+ if (newAttrValues[attrIdx].length === 0) continue;
51
+
52
+ // Sign-extend values for signed types — the pseudocode produces unsigned
53
+ // values from bitwise operations, but the attribute type declares signedness
54
+ if (attrDef.t && isAttrTypeSigned(attrDef.t)) {
55
+ const byteWidth = structSizeOf(attrDef.t);
56
+ const signBit = 1 << (byteWidth * 8 - 1);
57
+ const range = signBit * 2;
58
+ newAttrValues[attrIdx] = newAttrValues[attrIdx].map(v => {
59
+ const n = v as number;
60
+ return (n & signBit) ? n - range : n;
61
+ });
62
+ }
63
+
64
+ if ("d" in attrDef && attrDef.d) {
65
+ const divisor = attrDef.d as number;
66
+ newAttrValues[attrIdx] = newAttrValues[attrIdx].map(v => (v as number) / divisor);
67
+ }
68
+ if ("a" in attrDef && attrDef.a !== undefined) {
69
+ const addend = attrDef.a as number;
70
+ newAttrValues[attrIdx] = newAttrValues[attrIdx].map(v => (v as number) + addend);
71
+ }
72
+ }
45
73
 
46
74
  } else {
47
75
 
@@ -61,16 +89,15 @@ export default class AttributeHandler {
61
89
  // console.log(`RaftAttrHdlr.processMsgAttrGroup attr ${attrDef.n} msgBufIdx ${msgBufIdx} timestampUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`);
62
90
 
63
91
  // Process the attribute
64
- const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx);
92
+ const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, boundedMsgEndIdx);
65
93
  if (newMsgBufIdx < 0) {
66
- newAttrValues.push([]);
67
- continue;
94
+ return -1;
68
95
  }
69
96
  msgBufIdx = newMsgBufIdx;
70
97
  newAttrValues.push(values);
71
98
  }
72
99
  }
73
-
100
+
74
101
  // Number of bytes in group
75
102
  let pollRespSizeBytes = msgBufIdx - msgDataStartIdx;
76
103
  if (pollRespSizeBytes < pollRespMetadata.b) {
@@ -80,7 +107,7 @@ export default class AttributeHandler {
80
107
  // Check if any attributes were added (in addition to timestamp)
81
108
  if (newAttrValues.length === 0) {
82
109
  console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} newAttrValues ${newAttrValues} is empty`);
83
- return msgDataStartIdx+pollRespSizeBytes;
110
+ return -1;
84
111
  }
85
112
 
86
113
  // All attributes must have the same number of new values
@@ -129,13 +156,56 @@ export default class AttributeHandler {
129
156
  devAttrsState[attrDef.n].numNewValues = newAttrValues[attrIdx].length;
130
157
  }
131
158
 
132
- // Handle the timestamps with increments if specified
159
+ // --- Piecewise EMA timestamp reconstruction ---
160
+ // Track the last assigned sample timestamp and step forward by emaIntervalUs
161
+ // per sample. No T0 recomputation — avoids amplified batch-boundary jitter.
133
162
  const timeIncUs: number = pollRespMetadata.us ? pollRespMetadata.us : 1000;
163
+
164
+ if (!deviceTimeline.emaCalibrated) {
165
+ // Cold start: anchor so that sample 0 gets the poll timestamp
166
+ deviceTimeline.emaIntervalUs = timeIncUs;
167
+ deviceTimeline.emaLastSampleTimeUs = timestampUs - deviceTimeline.emaIntervalUs;
168
+ deviceTimeline.emaPrevPollTimeUs = timestampUs;
169
+ deviceTimeline.emaCalibrated = true;
170
+ deviceTimeline.emaCalibrationPolls = 1;
171
+ } else {
172
+ // EMA interval update from poll-to-poll gap
173
+ if (numNewDataPoints > 1) {
174
+ const instantIntervalUs = (timestampUs - deviceTimeline.emaPrevPollTimeUs) / numNewDataPoints;
175
+ const alpha = deviceTimeline.emaCalibrationPolls < 20 ? 0.3 : 0.05;
176
+ deviceTimeline.emaIntervalUs = alpha * instantIntervalUs
177
+ + (1.0 - alpha) * deviceTimeline.emaIntervalUs;
178
+ } else if (numNewDataPoints === 1) {
179
+ const instantIntervalUs = timestampUs - deviceTimeline.emaPrevPollTimeUs;
180
+ if (Number.isFinite(instantIntervalUs) && instantIntervalUs > 0) {
181
+ deviceTimeline.emaIntervalUs = instantIntervalUs;
182
+ }
183
+ }
184
+ deviceTimeline.emaPrevPollTimeUs = timestampUs;
185
+ deviceTimeline.emaCalibrationPolls++;
186
+ }
187
+
188
+ // Assign timestamps: each sample steps forward by emaIntervalUs
134
189
  const timestampsUs = Array(numNewDataPoints).fill(0);
190
+ const lastTimeUs = deviceTimeline.timestampsUs.length > 0
191
+ ? deviceTimeline.timestampsUs[deviceTimeline.timestampsUs.length - 1]
192
+ : -Infinity;
135
193
  for (let i = 0; i < numNewDataPoints; i++) {
136
- timestampsUs[i] = timestampUs + i * timeIncUs;
194
+ timestampsUs[i] = numNewDataPoints === 1
195
+ ? timestampUs
196
+ : deviceTimeline.emaLastSampleTimeUs + (i + 1) * deviceTimeline.emaIntervalUs;
197
+ // Ensure monotonically increasing timestamps
198
+ if (i === 0 && timestampsUs[0] <= lastTimeUs) {
199
+ timestampsUs[0] = lastTimeUs + 1;
200
+ } else if (i > 0 && timestampsUs[i] <= timestampsUs[i - 1]) {
201
+ timestampsUs[i] = timestampsUs[i - 1] + 1;
202
+ }
203
+ }
204
+ // Advance the piecewise model cursor past all samples in this batch
205
+ if (deviceTimeline.emaCalibrated && numNewDataPoints > 0) {
206
+ deviceTimeline.emaLastSampleTimeUs = timestampsUs[timestampsUs.length - 1];
137
207
  }
138
-
208
+
139
209
  // Check if timeline points need to be discarded
140
210
  const discardCount = Math.max(0, deviceTimeline.timestampsUs.length + timestampsUs.length - maxDataPoints);
141
211
  if (discardCount > 0) {
@@ -144,6 +214,7 @@ export default class AttributeHandler {
144
214
 
145
215
  // Add the new timestamps
146
216
  deviceTimeline.timestampsUs.push(...timestampsUs);
217
+ deviceTimeline.totalSamplesAdded += numNewDataPoints;
147
218
 
148
219
  // Validate attributes based on the vft field
149
220
  this.validateAttributes(pollRespMetadata, devAttrsState, numNewDataPoints);
@@ -156,7 +227,7 @@ export default class AttributeHandler {
156
227
  // Iterate through all attributes to find those with a vft field
157
228
  for (let attrIdx = 0; attrIdx < pollRespMetadata.a.length; attrIdx++) {
158
229
  const attrDef: DeviceTypeAttribute = pollRespMetadata.a[attrIdx];
159
-
230
+
160
231
  // Check if this attribute has a vft field
161
232
  if (!("vft" in attrDef) || !attrDef.vft) {
162
233
  continue;
@@ -183,7 +254,7 @@ export default class AttributeHandler {
183
254
  // Get the most recent values from both attributes
184
255
  const numValues = currentAttr.values.length;
185
256
  const startIdx = numValues - numNewDataPoints;
186
-
257
+
187
258
  // Process each of the new values
188
259
  for (let i = 0; i < numNewDataPoints; i++) {
189
260
  const valueIdx = startIdx + i;
@@ -201,34 +272,39 @@ export default class AttributeHandler {
201
272
  }
202
273
  }
203
274
 
204
- private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number): { values: number[], newMsgBufIdx: number} {
275
+ private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number, msgEndIdx: number): { values: (number | string)[], newMsgBufIdx: number} {
205
276
 
206
277
  // Current field message string index
207
278
  let curFieldBufIdx = msgBufIdx;
208
279
  let attrUsesAbsPos = false;
280
+ const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgDataStartIdx), msgBuffer.length);
281
+ const attrTypesOnly = attrDef.t;
282
+ const numBytesConsumed = structSizeOf(attrTypesOnly);
209
283
 
210
284
  // Check for "at" field which means absolute position in the buffer
211
285
  if (attrDef.at !== undefined) {
212
286
  // Handle both single value and array of byte positions
213
287
  if (Array.isArray(attrDef.at)) {
214
288
  // Create a new buffer for non-contiguous data extraction
215
- const elemSize = structSizeOf(attrDef.t);
289
+ const elemSize = numBytesConsumed;
216
290
  const bytesForType = new Uint8Array(elemSize);
217
-
291
+
218
292
  // Zero out the buffer
219
293
  bytesForType.fill(0);
220
-
294
+
221
295
  // Copy bytes from the specified positions
222
296
  for (let i = 0; i < attrDef.at.length && i < elemSize; i++) {
223
297
  const sourceIdx = msgDataStartIdx + attrDef.at[i];
224
- if (sourceIdx < msgBuffer.length) {
225
- bytesForType[i] = msgBuffer[sourceIdx];
298
+ if (sourceIdx >= boundedMsgEndIdx) {
299
+ return { values: [], newMsgBufIdx: -1 };
226
300
  }
301
+ bytesForType[i] = msgBuffer[sourceIdx];
227
302
  }
228
-
303
+
229
304
  // Use this buffer for attribute extraction
230
305
  msgBuffer = bytesForType;
231
306
  curFieldBufIdx = 0;
307
+ msgEndIdx = bytesForType.length;
232
308
  } else {
233
309
  // Standard absolute position in the buffer
234
310
  curFieldBufIdx = msgDataStartIdx + attrDef.at;
@@ -237,26 +313,25 @@ export default class AttributeHandler {
237
313
  }
238
314
 
239
315
  // Check if outside bounds of message
240
- if (curFieldBufIdx >= msgBuffer.length) {
316
+ const attrEndIdx = curFieldBufIdx + numBytesConsumed;
317
+ const effectiveMsgEndIdx = Math.min(Math.max(msgEndIdx, curFieldBufIdx), msgBuffer.length);
318
+ if (curFieldBufIdx >= effectiveMsgEndIdx || attrEndIdx > effectiveMsgEndIdx) {
241
319
  // console.warn(`DeviceManager msg outside bounds msgBuffer ${msgBuffer} attrName ${attrDef.n}`);
242
320
  return { values: [], newMsgBufIdx: -1 };
243
321
  }
244
322
 
245
- // Attribute type
246
- const attrTypesOnly = attrDef.t;
247
-
248
323
  // Slice into buffer
249
- const attrBuf = msgBuffer.slice(curFieldBufIdx);
250
-
324
+ const attrBuf = msgBuffer.slice(curFieldBufIdx, effectiveMsgEndIdx);
325
+
251
326
  // Check if a mask is used and the value is signed
252
327
  const maskOnSignedValue = "m" in attrDef && isAttrTypeSigned(attrTypesOnly);
253
328
 
254
329
  // Extract the value using python-struct
255
330
  const unpackValues = structUnpack(maskOnSignedValue ? attrTypesOnly.toUpperCase() : attrTypesOnly, attrBuf);
256
- let attrValues = unpackValues as number[];
331
+ let attrValues = unpackValues as (number | string)[];
257
332
 
258
- // Get number of bytes consumed
259
- const numBytesConsumed = structSizeOf(attrTypesOnly);
333
+ // Check if any values are strings (from 's' format) — skip numeric transforms for those
334
+ const hasStringValues = attrValues.some(v => typeof v === 'string');
260
335
 
261
336
  // // Check if sign extendable mask specified on signed value
262
337
  // if (mmSpecifiedOnSignedValue) {
@@ -270,82 +345,82 @@ export default class AttributeHandler {
270
345
  // }
271
346
 
272
347
  // Check for XOR mask
273
- if ("x" in attrDef) {
348
+ if (!hasStringValues && "x" in attrDef) {
274
349
  const mask = typeof attrDef.x === "string" ? parseInt(attrDef.x, 16) : attrDef.x as number;
275
- attrValues = attrValues.map((value) => (value >>> 0) ^ mask);
350
+ attrValues = attrValues.map((value) => ((value as number) >>> 0) ^ mask);
276
351
  }
277
-
352
+
278
353
  // Check for AND mask
279
- if ("m" in attrDef) {
354
+ if (!hasStringValues && "m" in attrDef) {
280
355
  const mask = typeof attrDef.m === "string" ? parseInt(attrDef.m, 16) : attrDef.m as number;
281
- attrValues = attrValues.map((value) => (maskOnSignedValue ? this.signExtend(value, mask) : (value >>> 0) & mask));
356
+ attrValues = attrValues.map((value) => (maskOnSignedValue ? this.signExtend(value as number, mask) : ((value as number) >>> 0) & mask));
282
357
  }
283
358
 
284
359
  // Check for a sign-bit
285
- if ("sb" in attrDef) {
360
+ if (!hasStringValues && "sb" in attrDef) {
286
361
  const signBitPos = attrDef.sb as number;
287
362
  const signBitMask = 1 << signBitPos;
288
363
  if ("ss" in attrDef) {
289
364
  const signBitSubtract = attrDef.ss as number;
290
- attrValues = attrValues.map((value) => (value & signBitMask) ? signBitSubtract - value : value);
365
+ attrValues = attrValues.map((value) => ((value as number) & signBitMask) ? signBitSubtract - (value as number) : value);
291
366
  } else {
292
- attrValues = attrValues.map((value) => (value & signBitMask) ? value - (signBitMask << 1) : value);
367
+ attrValues = attrValues.map((value) => ((value as number) & signBitMask) ? (value as number) - (signBitMask << 1) : value);
293
368
  }
294
369
  }
295
370
 
296
371
  // Check for bit shift required
297
- if ("s" in attrDef && attrDef.s) {
372
+ if (!hasStringValues && "s" in attrDef && attrDef.s) {
298
373
  const bitshift = attrDef.s as number;
299
374
  if (bitshift > 0) {
300
- attrValues = attrValues.map((value) => (value >>> 0) >>> bitshift);
375
+ attrValues = attrValues.map((value) => ((value as number) >>> 0) >>> bitshift);
301
376
  } else if (bitshift < 0) {
302
- attrValues = attrValues.map((value) => (value >>> 0) << -bitshift);
377
+ attrValues = attrValues.map((value) => ((value as number) >>> 0) << -bitshift);
303
378
  }
304
379
  }
305
380
 
306
381
  // Check for divisor
307
- if ("d" in attrDef && attrDef.d) {
382
+ if (!hasStringValues && "d" in attrDef && attrDef.d) {
308
383
  const divisor = attrDef.d as number;
309
- attrValues = attrValues.map((value) => (value) / divisor);
384
+ attrValues = attrValues.map((value) => (value as number) / divisor);
310
385
  }
311
386
 
312
387
  // Check for value to add
313
- if ("a" in attrDef && attrDef.a !== undefined) {
388
+ if (!hasStringValues && "a" in attrDef && attrDef.a !== undefined) {
314
389
  const addValue = attrDef.a as number;
315
- attrValues = attrValues.map((value) => (value) + addValue);
390
+ attrValues = attrValues.map((value) => (value as number) + addValue);
316
391
  }
317
392
 
318
393
  // Apply lookup table if defined
319
- if ("lut" in attrDef && attrDef.lut !== undefined) {
394
+ if (!hasStringValues && "lut" in attrDef && attrDef.lut !== undefined) {
320
395
  attrValues = attrValues.map((value): number => {
321
396
  // Skip NaN values
322
- if (isNaN(value)) {
323
- return value;
397
+ if (isNaN(value as number)) {
398
+ return value as number;
324
399
  }
325
400
 
326
401
  // Search through the lookup table rows for a match
327
402
  let defaultValue: number | null = null;
328
-
403
+
329
404
  for (const row of attrDef.lut || []) {
330
405
  // Empty string means default for unmatched values
331
406
  if (row.r === "") {
332
407
  defaultValue = row.v;
333
408
  continue;
334
409
  }
335
-
410
+
336
411
  // Parse the range string
337
- if (this.isValueInRangeString(value, row.r)) {
412
+ if (this.isValueInRangeString(value as number, row.r)) {
338
413
  return row.v;
339
414
  }
340
415
  }
341
-
416
+
342
417
  // If no match found but we have a default, use it
343
418
  if (defaultValue !== null) {
344
419
  return defaultValue;
345
420
  }
346
-
421
+
347
422
  // Otherwise keep the original value
348
- return value;
423
+ return value as number;
349
424
  });
350
425
  }
351
426
 
@@ -363,38 +438,39 @@ export default class AttributeHandler {
363
438
  // Return the value
364
439
  return { values: attrValues, newMsgBufIdx: msgBufIdx };
365
440
  }
366
-
441
+
367
442
  private signExtend(value: number, mask: number): number {
368
443
  const signBitMask = (mask + 1) >> 1;
369
444
  const signBit = value & signBitMask;
370
-
445
+
371
446
  if (signBit !== 0) { // If sign bit is set
372
447
  const highBitsMask = ~mask & ~((mask + 1) >> 1);
373
448
  value |= highBitsMask; // Apply the sign extension
374
449
  }
375
-
450
+
376
451
  return value;
377
452
  }
378
453
 
379
- private extractTimestampAndAdvanceIdx(msgBuffer: Uint8Array, msgBufIdx: number, timestampWrapHandler: DeviceTimeline):
454
+ private extractTimestampAndAdvanceIdx(msgBuffer: Uint8Array, msgBufIdx: number, timestampWrapHandler: DeviceTimeline, msgEndIdx = msgBuffer.length):
380
455
  { newBufIdx: number, timestampUs: number } {
456
+ const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length);
381
457
 
382
458
  // Check there are enough bytes for the timestamp
383
- if (msgBufIdx + this.POLL_RESULT_TIMESTAMP_SIZE > msgBuffer.length) {
459
+ if (msgBufIdx + this.POLL_RESULT_TIMESTAMP_SIZE > boundedMsgEndIdx) {
384
460
  return { newBufIdx: -1, timestampUs: 0 };
385
461
  }
386
462
 
387
463
  // Use struct to extract the timestamp
388
464
  const tsBuffer = msgBuffer.slice(msgBufIdx, msgBufIdx + this.POLL_RESULT_TIMESTAMP_SIZE);
389
465
  let timestampUs: number;
390
- if (this.POLL_RESULT_TIMESTAMP_SIZE === 2) {
466
+ if (this.POLL_RESULT_TIMESTAMP_SIZE === 2) {
391
467
  timestampUs = structUnpack(">H", tsBuffer)[0] as number * this.POLL_RESULT_RESOLUTION_US;
392
468
  } else {
393
469
  timestampUs = structUnpack(">I", tsBuffer)[0] as number * this.POLL_RESULT_RESOLUTION_US;
394
470
  }
395
471
 
396
472
  // Check if time is before lastReportTimeMs by more than 100ms - in which case a wrap around occurred to add on the max value
397
- if (timestampUs + 100000 < timestampWrapHandler.lastReportTimestampUs ) {
473
+ if (timestampUs + 10000 < timestampWrapHandler.lastReportTimestampUs ) {
398
474
  timestampWrapHandler.reportTimestampOffsetUs += this.POLL_RESULT_WRAP_VALUE * this.POLL_RESULT_RESOLUTION_US;
399
475
  }
400
476
  timestampWrapHandler.lastReportTimestampUs = timestampUs;
@@ -413,38 +489,38 @@ export default class AttributeHandler {
413
489
  private isValueInRangeString(value: number, rangeStr: string): boolean {
414
490
  // Round to integer for comparison
415
491
  const roundedValue = Math.round(value);
416
-
492
+
417
493
  // Split the range string by commas
418
494
  const parts = rangeStr.split(',');
419
-
495
+
420
496
  for (const part of parts) {
421
497
  // Check if it's a range (contains a hyphen)
422
498
  if (part.includes('-')) {
423
499
  const [startStr, endStr] = part.split('-');
424
-
500
+
425
501
  // Handle hex values
426
- const start = startStr.toLowerCase().startsWith('0x') ?
502
+ const start = startStr.toLowerCase().startsWith('0x') ?
427
503
  parseInt(startStr, 16) : parseInt(startStr, 10);
428
- const end = endStr.toLowerCase().startsWith('0x') ?
504
+ const end = endStr.toLowerCase().startsWith('0x') ?
429
505
  parseInt(endStr, 16) : parseInt(endStr, 10);
430
-
506
+
431
507
  if (!isNaN(start) && !isNaN(end) && roundedValue >= start && roundedValue <= end) {
432
508
  return true;
433
509
  }
434
- }
510
+ }
435
511
  // Check if it's a single value
436
512
  else {
437
513
  // Handle hex values
438
- const partValue = part.toLowerCase().startsWith('0x') ?
514
+ const partValue = part.toLowerCase().startsWith('0x') ?
439
515
  parseInt(part, 16) : parseInt(part, 10);
440
-
516
+
441
517
  if (!isNaN(partValue) && roundedValue === partValue) {
442
518
  return true;
443
519
  }
444
520
  }
445
521
  }
446
-
522
+
447
523
  return false;
448
524
  }
449
525
 
450
- }
526
+ }
@@ -41,6 +41,7 @@ export default class RaftChannelBLE implements RaftChannel {
41
41
  // Connected flag and retries
42
42
  private _isConnected = false;
43
43
  private readonly _maxConnRetries = 3;
44
+ private readonly _connRetryDelayMs = 500;
44
45
 
45
46
  // Event listener fn
46
47
  private _eventListenerFn: ((event: Event) => void) | null = null;
@@ -49,6 +50,11 @@ export default class RaftChannelBLE implements RaftChannel {
49
50
  private _requestedBatchAckSize = 10;
50
51
  private _requestedFileBlockSize = 500;
51
52
 
53
+ // Max bytes per BLE write - messages larger than this are split into chunks.
54
+ // Web Bluetooth doesn't expose the negotiated MTU, so use a conservative
55
+ // default that works with BLE 4.2+ (ATT_MTU 251 → payload 244).
56
+ private _maxBleWriteSize = 244;
57
+
52
58
  fhBatchAckSize(): number {
53
59
  return this._requestedBatchAckSize;
54
60
  }
@@ -115,6 +121,18 @@ export default class RaftChannelBLE implements RaftChannel {
115
121
  return this._bleDevice || "";
116
122
  }
117
123
 
124
+ private async disconnectGattBeforeRetry(): Promise<void> {
125
+ if (!this._bleDevice?.gatt?.connected) {
126
+ return;
127
+ }
128
+
129
+ try {
130
+ await this._bleDevice.gatt.disconnect();
131
+ } catch (error) {
132
+ RaftLog.warn(`RaftChannelBLE.connect - cannot disconnect before retry ${error}`);
133
+ }
134
+ }
135
+
118
136
  // Connect to a device
119
137
  async connect(locator: string | object, _connectorOptions: ConnectorOptions): Promise<boolean> {
120
138
  // RaftLog.debug(`Selected device: ${deviceID}`);
@@ -154,10 +172,19 @@ export default class RaftChannelBLE implements RaftChannel {
154
172
  }
155
173
 
156
174
  if (!service) {
175
+ if (connRetry === this._maxConnRetries - 1) {
176
+ RaftLog.warn(
177
+ `RaftChannelBLE.connect - cannot get primary service - attempt #${connRetry + 1} - giving up`
178
+ );
179
+ return false;
180
+ }
181
+
157
182
  RaftLog.warn(
158
- `RaftChannelBLE.connect - cannot get primary service - giving up`
183
+ `RaftChannelBLE.connect - cannot get primary service - attempt #${connRetry + 1} - retrying`
159
184
  );
160
- return false;
185
+ await this.disconnectGattBeforeRetry();
186
+ await new Promise(resolve => setTimeout(resolve, this._connRetryDelayMs));
187
+ continue;
161
188
  }
162
189
  RaftLog.debug(
163
190
  `RaftChannelBLE.connect - found service: ${service.uuid}`
@@ -286,7 +313,20 @@ export default class RaftChannelBLE implements RaftChannel {
286
313
  }
287
314
  }
288
315
 
289
- // Send a message
316
+ // Write a single chunk to the BLE characteristic
317
+ private async _writeChunk(data: Uint8Array): Promise<void> {
318
+ if (!this._characteristicTx) return;
319
+ const bs = RaftUtils.toBufferSource(data);
320
+ if (this._characteristicTx.writeValueWithoutResponse) {
321
+ await this._characteristicTx.writeValueWithoutResponse(bs);
322
+ } else if (this._characteristicTx.writeValue) {
323
+ await this._characteristicTx.writeValue(bs);
324
+ } else if (this._characteristicTx.writeValueWithResponse) {
325
+ await this._characteristicTx.writeValueWithResponse(bs);
326
+ }
327
+ }
328
+
329
+ // Send a message, chunking if it exceeds the BLE write size
290
330
  async sendTxMsg(
291
331
  msg: Uint8Array
292
332
  // _sendWithResponse: boolean
@@ -304,16 +344,16 @@ export default class RaftChannelBLE implements RaftChannel {
304
344
  }
305
345
  this._msgTxTimeLast = Date.now();
306
346
 
307
- // Write to the characteristic
347
+ // Write to the characteristic, chunking if necessary
308
348
  try {
309
349
  if (this._characteristicTx) {
310
- const bs = RaftUtils.toBufferSource(msg);
311
- if (this._characteristicTx.writeValueWithoutResponse) {
312
- await this._characteristicTx.writeValueWithoutResponse(bs);
313
- } else if (this._characteristicTx.writeValue) {
314
- await this._characteristicTx.writeValue(bs);
315
- } else if (this._characteristicTx.writeValueWithResponse) {
316
- await this._characteristicTx.writeValueWithResponse(bs);
350
+ if (msg.length <= this._maxBleWriteSize) {
351
+ await this._writeChunk(msg);
352
+ } else {
353
+ for (let offset = 0; offset < msg.length; offset += this._maxBleWriteSize) {
354
+ const chunk = msg.subarray(offset, Math.min(offset + this._maxBleWriteSize, msg.length));
355
+ await this._writeChunk(chunk);
356
+ }
317
357
  }
318
358
  }
319
359
  break;
@@ -344,15 +384,17 @@ export default class RaftChannelBLE implements RaftChannel {
344
384
  }
345
385
  this._msgTxTimeLast = Date.now();
346
386
 
347
- // Write to the characteristic
387
+ // Write to the characteristic, chunking if necessary
348
388
  if (this._characteristicTx) {
349
- const bs = RaftUtils.toBufferSource(msg);
350
- if (this._characteristicTx.writeValueWithoutResponse) {
351
- this._characteristicTx.writeValueWithoutResponse(bs);
352
- } else if (this._characteristicTx.writeValue) {
353
- this._characteristicTx.writeValue(bs);
354
- } else if (this._characteristicTx.writeValueWithResponse) {
355
- this._characteristicTx.writeValueWithResponse(bs);
389
+ if (msg.length <= this._maxBleWriteSize) {
390
+ this._writeChunk(msg);
391
+ } else {
392
+ (async () => {
393
+ for (let offset = 0; offset < msg.length; offset += this._maxBleWriteSize) {
394
+ const chunk = msg.subarray(offset, Math.min(offset + this._maxBleWriteSize, msg.length));
395
+ await this._writeChunk(chunk);
396
+ }
397
+ })();
356
398
  }
357
399
  return true;
358
400
  }
@@ -371,4 +413,4 @@ export default class RaftChannelBLE implements RaftChannel {
371
413
  return null as T;
372
414
  }
373
415
 
374
- }
416
+ }
@@ -23,6 +23,8 @@ interface SimulatedDeviceInfo {
23
23
 
24
24
  export default class RaftChannelSimulated implements RaftChannel {
25
25
 
26
+ private readonly POLL_RESULT_TIMESTAMP_RESOLUTION_US = 100;
27
+
26
28
  // Message handler
27
29
  private _raftMsgHandler: RaftMsgHandler | null = null;
28
30
 
@@ -292,8 +294,10 @@ export default class RaftChannelSimulated implements RaftChannel {
292
294
  const dataView = new DataView(dataBuffer);
293
295
  let bytePos = 0;
294
296
 
295
- // Add 16 bit big endian deviceTimeMs mod 65536 to the buffer
296
- dataView.setUint16(bytePos, deviceTimeMs % 65536, false);
297
+ // Add 16-bit big-endian timestamp ticks. Poll timestamps are decoded in
298
+ // 100us units by RaftAttributeHandler.
299
+ const timestampTicks = Math.floor((deviceTimeMs * 1000) / this.POLL_RESULT_TIMESTAMP_RESOLUTION_US) % 65536;
300
+ dataView.setUint16(bytePos, timestampTicks, false);
297
301
  bytePos += 2;
298
302
 
299
303
  const handledByCustomGenerator = this._fillCustomRawData(
@@ -357,9 +361,9 @@ export default class RaftChannelSimulated implements RaftChannel {
357
361
  // Convert the buffer to a byte array
358
362
  const dataBytes = new Uint8Array(dataBuffer);
359
363
 
360
- // Create the JSON message structure
364
+ // Create the JSON message structure (bus key is numeric string to match firmware convention)
361
365
  const message = {
362
- "BUS1": {
366
+ "1": {
363
367
  [deviceName]: {
364
368
  "_t": deviceTypeInfo.type,
365
369
  "_o": 1, // Device is online
@@ -464,7 +468,8 @@ export default class RaftChannelSimulated implements RaftChannel {
464
468
  const hotspotRow = (Math.sin(hotspotPhase) + 1) * (rows - 1) / 2;
465
469
  const hotspotCol = (Math.cos(hotspotPhase) + 1) * (cols - 1) / 2;
466
470
  const hotspotAmplitude = 6;
467
- const sigma = Math.max(rows, cols) / 3 || 1;
471
+ const rawSigma = Math.max(rows, cols) / 3;
472
+ const sigma = Number.isFinite(rawSigma) ? rawSigma : 1;
468
473
 
469
474
  for (let idx = 0; idx < repeatCount; idx++) {
470
475
  const row = Math.floor(idx / cols);