@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
@@ -7,14 +7,14 @@
7
7
  //
8
8
  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
9
9
 
10
- import { DeviceAttributeState, DeviceAttributesState, DevicesState, DeviceState, getDeviceKey } from "./RaftDeviceStates";
10
+ import { DeviceAttributeState, DeviceAttributesState, DevicesState, DeviceState, DeviceStats, DeviceOnlineState, formatDeviceAddrHex, getDeviceKey, parseDeviceKey } from "./RaftDeviceStates";
11
11
  import { DeviceMsgJson } from "./RaftDeviceMsg";
12
12
  import { RaftOKFail } from './RaftTypes';
13
- import { DeviceTypeInfo, DeviceTypeAction, DeviceTypeInfoRecs, RaftDevTypeInfoResponse } from "./RaftDeviceInfo";
13
+ import { DeviceTypeInfo, DeviceTypeAction, DeviceTypeInfoRecs, DeviceTypePollRespMetadata, RaftDevTypeInfoResponse, SampleRateResult, getActionMapHex } from "./RaftDeviceInfo";
14
14
  import AttributeHandler from "./RaftAttributeHandler";
15
15
  import RaftSystemUtils from "./RaftSystemUtils";
16
16
  import RaftDeviceMgrIF from "./RaftDeviceMgrIF";
17
- import { structPack } from "./RaftStruct";
17
+ import { structPack, structSizeOf } from "./RaftStruct";
18
18
  // import RaftUtils from "./RaftUtils";
19
19
 
20
20
  export interface DeviceDecodedData {
@@ -23,12 +23,18 @@ export interface DeviceDecodedData {
23
23
  deviceAddress: string;
24
24
  deviceType: string;
25
25
  attrGroupName?: string;
26
- attrValues: Record<string, number[]>;
26
+ attrValues: Record<string, (number | string)[]>;
27
27
  timestampsUs: number[];
28
28
  markers?: Record<string, unknown>;
29
29
  fromOfflineBuffer?: boolean;
30
30
  }
31
31
 
32
+ interface DeviceStatsInternal extends DeviceStats {
33
+ windowEvents: Array<{ timeMs: number; samples: number }>;
34
+ }
35
+
36
+ type BinaryRecordPayloadFormat = "lengthPrefixed" | "legacyRaw";
37
+
32
38
  export class DeviceManager implements RaftDeviceMgrIF{
33
39
 
34
40
  // Max data points to store
@@ -58,10 +64,16 @@ export class DeviceManager implements RaftDeviceMgrIF{
58
64
  private _newDeviceAttributeCallbacks: Array<(deviceKey: string, attrState: DeviceAttributeState) => void> = [];
59
65
  private _newAttributeDataCallbacks: Array<(deviceKey: string, attrState: DeviceAttributeState) => void> = [];
60
66
  private _decodedDataCallbacks: Array<(decoded: DeviceDecodedData) => void> = [];
67
+ private _deviceRemovedCallbacks: Array<(deviceKey: string, state: DeviceState) => void> = [];
61
68
 
62
69
  // Debug message index (to help debug with async messages)
63
70
  private _debugMsgIndex = 0;
64
71
 
72
+ // Device stats (sample counts, rates)
73
+ private _statsWindowMs = 5000;
74
+ private _deviceStats: { [deviceKey: string]: DeviceStatsInternal } = {};
75
+ private _malformedSampleWarnLastMs: { [warningKey: string]: number } = {};
76
+
65
77
  public getDevicesState(): DevicesState {
66
78
  return this._devicesState;
67
79
  }
@@ -70,6 +82,14 @@ export class DeviceManager implements RaftDeviceMgrIF{
70
82
  return this._devicesState[deviceKey];
71
83
  }
72
84
 
85
+ public getDeviceStats(deviceKey: string): DeviceStats {
86
+ return this.cloneDeviceStats(this.getOrCreateDeviceStats(deviceKey));
87
+ }
88
+
89
+ public resetDeviceStats(deviceKey: string): void {
90
+ this._deviceStats[deviceKey] = this.createEmptyStats();
91
+ }
92
+
73
93
  // Cached device type data
74
94
  private _cachedDeviceTypeRecs: DeviceTypeInfoRecs = {};
75
95
 
@@ -169,6 +189,16 @@ export class DeviceManager implements RaftDeviceMgrIF{
169
189
  this._decodedDataCallbacks = this._decodedDataCallbacks.filter((cb) => cb !== callback);
170
190
  }
171
191
 
192
+ public addDeviceRemovedCallback(callback: (deviceKey: string, state: DeviceState) => void): void {
193
+ if (!this._deviceRemovedCallbacks.includes(callback)) {
194
+ this._deviceRemovedCallbacks.push(callback);
195
+ }
196
+ }
197
+
198
+ public removeDeviceRemovedCallback(callback: (deviceKey: string, state: DeviceState) => void): void {
199
+ this._deviceRemovedCallbacks = this._deviceRemovedCallbacks.filter((cb) => cb !== callback);
200
+ }
201
+
172
202
  ////////////////////////////////////////////////////////////////////////////
173
203
  // Set the friendly name for the device
174
204
  ////////////////////////////////////////////////////////////////////////////
@@ -185,93 +215,205 @@ export class DeviceManager implements RaftDeviceMgrIF{
185
215
  public async handleClientMsgBinary(rxMsg: Uint8Array) {
186
216
  // console.log(`DeviceManager client1 msg ${RaftUtils.bufferToHex(rxMsg)}`);
187
217
 
188
- // Example messages
189
- // 0080 0015 81 0000006a 0004 53b7 feff00000100081857079314 0011 80 00000000 0011 53b2 075106e400d60054 0010 80 00000000 0012 5231 000d0000010e01
190
- // 0080 0011 80 00000000 0002 4ae1 0787052606240007 000e 80 00000000 0003 0006 030001af01
191
- // 0080 0011 80 00000000 0002 e46e 061e05a206830433 0010 80000000000003e4760006030001c701
192
- // 0080 0010 81 00000015 0004 e4a2 0650fe00305002 0011 80000000000002e4a8061f059f06850438 001080000000000003e4aa0006030001c701
193
-
194
- // 0080 0011 80 00000000 0002 31e4 05ea05a506660137 000e 80 00000000 0003 0007 030001d901
195
-
196
- // 0080 0011 80 00000000 0002 4d63 0792053e06500061 000e 80 00000000 0003 0005 030001de01
197
-
198
- // First two bytes of each message are the message type (0080)
199
- // There are then a series of sections each of which is the data for a device
200
- // First two bytes of each section is the section length (big endian) not including the section length bytes themselves
201
- // Next byte is the connection mode (0 for direct connect, 1+ for bus number) and the MSB of this byte is 1 if the device is online
202
- // Next is the device address (4 bytes big endian)
203
- // Next is the device type index (2 bytes big endian)
204
- // Finally the device data which can be one or more groups of attributes defined by the schema
218
+ // DevBIN message format
219
+ //
220
+ // The rxMsg passed to this function has a 2-byte message type prefix (e.g. 0x0080)
221
+ // added by the transport layer. After that prefix comes a devbin frame:
222
+ //
223
+ // Current devbin envelope (3 bytes):
224
+ // Byte 0: magic+version 0xDB (valid range 0xDB–0xDF)
225
+ // Byte 1: topicIndex 0x00–0xFE = topic index; 0xFF = no topic
226
+ // Byte 2: envelopeSeqNum uint8, wrapping detects whole-frame drops
227
+ //
228
+ // Then zero or more per-device records, concatenated back-to-back:
229
+ // Bytes 0-1: recordLen uint16 big-endian number of body bytes that follow (min 8)
230
+ // Byte 2: statusBus bit 7 = online flag, bit 6 = pending deletion, bits 3:0 = bus number
231
+ // Bytes 3-6: address uint32 big-endian device address on the bus
232
+ // Bytes 7-8: devTypeIdx uint16 big-endian device type table index
233
+ // Byte 9: deviceSeqNum uint8, wrapping per-device drop detection
234
+ // Bytes 10+: samples length-prefixed: [sampleLen(1B)][sampleData(sampleLen B)] × N
235
+ //
236
+ // Backwards compatibility:
237
+ // Cog v1.9.5 is already in production and sends the older RaftCore devbin layout:
238
+ // no 3-byte envelope, no deviceSeqNum byte, and raw fixed-size samples
239
+ // [timestamp(2B)][payload] × N. Keep that path separate so current Axiom/Cog
240
+ // frames continue to use the length-prefixed parser above.
241
+ //
242
+ // Example message (two device records; first record has two samples):
243
+ // 0080 DB 01 07 0018 81 0000076a 000b 2a 07feff0000010008 07185707931400 01 000e 80 00000000 001f 05 05030001af01
244
+ // ^^^^ ^^^^
245
+ // | ^^ ^^ ^^ Record 2 ...
246
+ // | | | envelopeSeqNum = 0x07 (same layout as Record 1)
247
+ // | | topicIndex = 0x01
248
+ // | magic+version = 0xDB (devbin v1)
249
+ // msgType prefix (transport layer)
250
+ //
251
+ // Record 1 breakdown:
252
+ // 0018 recordLen = 24 body bytes follow
253
+ // 81 statusBus: online=1, pendDel=0, bus=1
254
+ // 0000076a address = 0x0000076A (slot 7, I2C addr 0x6A)
255
+ // 000b devTypeIdx = 11
256
+ // 2a deviceSeqNum = 42
257
+ // 07 feff0000010008 sample 1: sampleLen=7, 7 bytes of attribute data
258
+ // 07 18570793140001 sample 2: sampleLen=7, 7 bytes of attribute data
259
+ //
260
+ // Record 2 breakdown:
261
+ // 000e recordLen = 14 body bytes follow
262
+ // 80 statusBus: online=1, pendDel=0, bus=0
263
+ // 00000000 address = 0x00000000 (direct-connect)
264
+ // 001f devTypeIdx = 31
265
+ // 05 deviceSeqNum = 5
266
+ // 05 030001af01 sample 1: sampleLen=5, 5 bytes of attribute data
205
267
 
206
268
  // Debug
207
269
  // const debugMsgTime = Date.now();
208
270
  const debugMsgIndex = this._debugMsgIndex++;
209
271
 
210
- // Message layout
211
- const msgTypeLen = 2; // Length of the message type (first two bytes)
212
- const sectionLengthLen = 2; // Length of the inclusive section length (first two bytes of each section)
213
- const sectionConnectionModeLen = 1; // Length of the connection mode (next byte after section length)
214
- const sectionDeviceAddrLen = 4; // Length of the device address (next 4 bytes after connection mode)
215
- const sectionDeviceTypeIdxLen = 2; // Length of the device type index (next 2 bytes after device address)
216
- const sectionHeaderLen = sectionConnectionModeLen + sectionDeviceAddrLen + sectionDeviceTypeIdxLen;
272
+ // Message layout constants
273
+ const msgTypeLen = 2; // Transport-layer message type prefix (first two bytes, e.g. 0x0080)
274
+ const devbinEnvelopeLen = 3; // Devbin envelope: magic+version (1B) + topicIndex (1B) + envelopeSeqNum (1B)
275
+ const legacyDevbinEnvelopeLen = 2; // Intermediate/legacy envelope: magic+version (1B) + topicIndex (1B)
276
+ const devbinMagicMin = 0xDB;
277
+ const devbinMagicMax = 0xDF;
278
+ const recordLenLen = 2; // Per-record length prefix (uint16 big-endian)
279
+ const busInfoLen = 1; // statusBus byte: bit 7 = online, bit 6 = pending deletion, bits 3:0 = bus number
280
+ const deviceAddrLen = 4; // Device address (uint32 big-endian)
281
+ const devTypeIdxLen = 2; // Device type index (uint16 big-endian)
282
+ const deviceSeqNumLen = 1; // Per-device sequence counter
283
+ const currentRecordHeaderLen = busInfoLen + deviceAddrLen + devTypeIdxLen + deviceSeqNumLen; // = 8, minimum record body
284
+ const legacyRecordHeaderLen = busInfoLen + deviceAddrLen + devTypeIdxLen; // = 7, Cog v1.9.5 record body header
217
285
 
218
286
  // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} rxMsg.length ${rxMsg.length} rxMsg ${RaftUtils.bufferToHex(rxMsg)}`);
219
287
 
220
288
  // Start after the message type
221
289
  let msgPos = msgTypeLen;
290
+ let payloadFormat: BinaryRecordPayloadFormat = "legacyRaw";
291
+
292
+ // Check for devbin envelope (magic+version + topicIndex)
293
+ if (rxMsg.length >= msgTypeLen + legacyDevbinEnvelopeLen) {
294
+ const envelopeMagicVer = rxMsg[msgTypeLen];
295
+ if ((envelopeMagicVer & 0xF0) === 0xD0) {
296
+ if ((envelopeMagicVer < devbinMagicMin) || (envelopeMagicVer > devbinMagicMax)) {
297
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid devbin envelope magic/version ${envelopeMagicVer}`);
298
+ return;
299
+ }
222
300
 
223
- // Iterate through sections
301
+ const topicIndex = rxMsg[msgTypeLen + 1];
302
+ if (topicIndex !== 0xFF) {
303
+ const topicName = this._systemUtils?.getPublishTopicName(topicIndex);
304
+ if (topicName && topicName !== "devbin") {
305
+ return;
306
+ }
307
+ }
308
+
309
+ const currentMsgPos = msgTypeLen + devbinEnvelopeLen;
310
+ const legacyMsgPos = msgTypeLen + legacyDevbinEnvelopeLen;
311
+ if (this.hasValidRecordAt(rxMsg, currentMsgPos, recordLenLen, currentRecordHeaderLen)) {
312
+ msgPos = currentMsgPos;
313
+ payloadFormat = "lengthPrefixed";
314
+ } else if (this.hasValidRecordAt(rxMsg, legacyMsgPos, recordLenLen, legacyRecordHeaderLen)) {
315
+ msgPos = legacyMsgPos;
316
+ payloadFormat = "legacyRaw";
317
+ } else {
318
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid devbin envelope payload`);
319
+ return;
320
+ }
321
+ }
322
+ } else if (this.hasValidRecordAt(rxMsg, msgPos, recordLenLen, currentRecordHeaderLen)) {
323
+ payloadFormat = "lengthPrefixed";
324
+ }
325
+
326
+ // Iterate through device records
224
327
  while (msgPos < rxMsg.length) {
225
328
 
226
- // Check length
329
+ // Check minimum length for record length prefix + record header
227
330
  const remainingLen = rxMsg.length - msgPos;
228
- if (remainingLen < sectionLengthLen + sectionHeaderLen) {
229
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid length ${rxMsg.length} < ${sectionLengthLen + sectionHeaderLen + msgPos}`);
331
+ if (remainingLen < recordLenLen + legacyRecordHeaderLen) {
332
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid length ${rxMsg.length} < ${recordLenLen + legacyRecordHeaderLen + msgPos}`);
230
333
  return;
231
334
  }
232
335
 
233
- // Get the length of the section
234
- const sectionLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1];
235
- if (sectionLen > remainingLen + sectionLengthLen) {
236
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid msgPos ${msgPos} msgLen ${sectionLen} remainingLenAfterLenBytes ${remainingLen-sectionLengthLen}`);
336
+ // Get the record body length (bytes that follow the 2-byte length prefix)
337
+ const recordLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1];
338
+ if ((recordLen < legacyRecordHeaderLen) || (recordLen > remainingLen - recordLenLen)) {
339
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid msgPos ${msgPos} recordLen ${recordLen} remainingAfterLenBytes ${remainingLen - recordLenLen}`);
237
340
  return;
238
341
  }
239
342
 
240
- // Extract message elements
241
- let sectionPos = msgPos + sectionLengthLen;
242
- const busNum = rxMsg[sectionPos] & 0x7f;
243
- const isOnline = (rxMsg[sectionPos] & 0x80) !== 0;
244
- sectionPos += sectionConnectionModeLen;
245
- // Get the device address and type index
246
- const devAddr = (rxMsg[sectionPos] << 24) + (rxMsg[sectionPos + 1] << 16) + (rxMsg[sectionPos + 2] << 8) + rxMsg[sectionPos + 3];
247
- sectionPos += sectionDeviceAddrLen;
248
- const devTypeIdx = (rxMsg[sectionPos] << 8) + rxMsg[sectionPos + 1];
249
- let attrGroupPos = sectionPos + sectionDeviceTypeIdxLen;
343
+ // Extract record header fields
344
+ let recordPos = msgPos + recordLenLen;
345
+
346
+ // statusBus byte: bit 7 = online, bit 6 = pending deletion, bits 3:0 = bus number
347
+ const statusByte = rxMsg[recordPos];
348
+ const busNum = statusByte & 0x0f;
349
+ const isOnline = (statusByte & 0x80) !== 0;
350
+ recordPos += busInfoLen;
351
+
352
+ // Device address (uint32 big-endian)
353
+ const devAddr = (rxMsg[recordPos] << 24) + (rxMsg[recordPos + 1] << 16) + (rxMsg[recordPos + 2] << 8) + rxMsg[recordPos + 3];
354
+ recordPos += deviceAddrLen;
355
+
356
+ // Device type index (uint16 big-endian)
357
+ const devTypeIdx = (rxMsg[recordPos] << 8) + rxMsg[recordPos + 1];
358
+ recordPos += devTypeIdxLen;
359
+
360
+ const commonRecordHeaderEndPos = recordPos;
361
+ const samplesEndPos = msgPos + recordLenLen + recordLen;
362
+ let recordPayloadFormat = payloadFormat;
363
+ let recordHeaderLen = recordPayloadFormat === "lengthPrefixed" ? currentRecordHeaderLen : legacyRecordHeaderLen;
364
+ const resolvedDeviceTypeInfo = await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString());
365
+ if (resolvedDeviceTypeInfo?.resp) {
366
+ recordPayloadFormat = this.resolveRecordPayloadFormat(rxMsg, commonRecordHeaderEndPos,
367
+ samplesEndPos, resolvedDeviceTypeInfo.resp, recordPayloadFormat, deviceSeqNumLen);
368
+ recordHeaderLen = recordPayloadFormat === "lengthPrefixed" ? currentRecordHeaderLen : legacyRecordHeaderLen;
369
+ }
370
+ if (recordLen < recordHeaderLen) {
371
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid msgPos ${msgPos} recordLen ${recordLen} recordHeaderLen ${recordHeaderLen}`);
372
+ return;
373
+ }
374
+
375
+ const isPendingDeletion = (recordPayloadFormat === "lengthPrefixed") && ((statusByte & 0x40) !== 0);
376
+ recordPos = commonRecordHeaderEndPos;
377
+ if (recordPayloadFormat === "lengthPrefixed") {
378
+ // Per-device sequence counter (reserved for future drop detection)
379
+ // const deviceSeqNum = rxMsg[recordPos];
380
+ recordPos += deviceSeqNumLen;
381
+ }
382
+
383
+ let pollDataPos = recordPos;
250
384
 
251
385
  // Debug
252
- // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} overallLen ${rxMsg.length} sectionPos ${msgPos} sectionLen ${sectionLen} ${attrGroupPos} ${RaftUtils.bufferToHex(rxMsg.slice(msgPos, msgPos + sectionLen))}`);
253
- // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} connMode ${busNum} isOnline ${isOnline} devAddr ${devAddr} devTypeIdx ${devTypeIdx} attrGroupDataLen ${sectionLen - sectionHeaderLen}`);
386
+ // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} overallLen ${rxMsg.length} recordStart ${msgPos} recordLen ${recordLen} ${pollDataPos} ${RaftUtils.bufferToHex(rxMsg.slice(msgPos, msgPos + recordLenLen + recordLen))}`);
387
+ // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} bus ${busNum} isOnline ${isOnline} devAddr 0x${devAddr.toString(16)} devTypeIdx ${devTypeIdx} pollDataLen ${recordLen - recordHeaderLen}`);
254
388
 
255
- // Device key
256
- const deviceKey = getDeviceKey(busNum.toString(), devAddr.toString(), devTypeIdx.toString());
389
+ // Format device address as canonical hex and build device key
390
+ const devAddrHex = formatDeviceAddrHex(devAddr);
391
+ const deviceKey = this.getBinaryDeviceKey(busNum, devAddrHex, devTypeIdx, recordPayloadFormat);
257
392
 
258
393
  // Update the last update time
259
394
  this._deviceLastUpdateTime[deviceKey] = Date.now();
260
395
 
396
+ // Handle pending deletion - remove device and skip further processing
397
+ if (isPendingDeletion) {
398
+ this.removeDevice(deviceKey);
399
+ msgPos += recordLenLen + recordLen;
400
+ continue;
401
+ }
402
+
261
403
  // Check if a device state already exists
262
404
  if (!(deviceKey in this._devicesState) || (this._devicesState[deviceKey].deviceTypeInfo === undefined)) {
263
405
 
264
406
  // Get the device type info
265
- const deviceTypeInfo = await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString());
407
+ const deviceTypeInfo = resolvedDeviceTypeInfo ?? await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString());
266
408
 
267
409
  // Debug
268
- // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} busNum ${busNum} devAddr ${devAddr} devTypeIdx ${devTypeIdx} deviceTypeInfo ${JSON.stringify(deviceTypeInfo)}`);
410
+ // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} busNum ${busNum} devAddr 0x${devAddr.toString(16)} devTypeIdx ${devTypeIdx} deviceTypeInfo ${JSON.stringify(deviceTypeInfo)}`);
269
411
 
270
412
  // Handle case where device type info is not available
271
413
  if (deviceTypeInfo === undefined) {
272
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} deviceType ${devTypeIdx} info not available, skipping attribute processing for this section`);
273
- // Skip to next section without processing attributes
274
- msgPos += sectionLengthLen + sectionLen;
414
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} deviceType ${devTypeIdx} info not available, skipping attribute processing for this record`);
415
+ // Skip to next record without processing attributes
416
+ msgPos += recordLenLen + recordLen;
275
417
  continue;
276
418
  }
277
419
 
@@ -281,7 +423,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
281
423
  this._devicesState[deviceKey].deviceTypeInfo = deviceTypeInfo;
282
424
  this._devicesState[deviceKey].deviceType = deviceTypeInfo.name || "";
283
425
  this._devicesState[deviceKey].busName = busNum.toString();
284
- this._devicesState[deviceKey].deviceAddress = devAddr.toString();
426
+ this._devicesState[deviceKey].deviceAddress = devAddrHex;
285
427
  }
286
428
  } else {
287
429
  // Create device record - device type info may be undefined
@@ -290,13 +432,19 @@ export class DeviceManager implements RaftDeviceMgrIF{
290
432
  deviceTimeline: {
291
433
  timestampsUs: [],
292
434
  lastReportTimestampUs: 0,
293
- reportTimestampOffsetUs: 0
435
+ reportTimestampOffsetUs: 0,
436
+ totalSamplesAdded: 0,
437
+ emaLastSampleTimeUs: 0,
438
+ emaIntervalUs: 0,
439
+ emaPrevPollTimeUs: 0,
440
+ emaCalibrated: false,
441
+ emaCalibrationPolls: 0
294
442
  },
295
443
  deviceAttributes: {},
296
444
  deviceIsNew: true,
297
445
  stateChanged: false,
298
- isOnline: true,
299
- deviceAddress: devAddr.toString(),
446
+ onlineState: DeviceOnlineState.Online,
447
+ deviceAddress: devAddrHex,
300
448
  deviceType: deviceTypeInfo?.name || "",
301
449
  busName: busNum.toString()
302
450
  };
@@ -305,7 +453,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
305
453
 
306
454
  // Get device state
307
455
  const deviceState = this._devicesState[deviceKey];
308
- deviceState.isOnline = isOnline;
456
+ deviceState.onlineState = isOnline ? DeviceOnlineState.Online : DeviceOnlineState.Offline;
309
457
 
310
458
  // Check if device type info is available and complete
311
459
  if (deviceState.deviceTypeInfo && deviceState.deviceTypeInfo.resp) {
@@ -313,59 +461,106 @@ export class DeviceManager implements RaftDeviceMgrIF{
313
461
  // Iterate over attributes in the group
314
462
  const pollRespMetadata = deviceState.deviceTypeInfo!.resp!;
315
463
 
316
- // Iterate over attribute groups
317
- const attrGroupDataLen = sectionLen - sectionHeaderLen;
318
- const attrGroupStartPos = attrGroupPos;
464
+ // Process samples within this record
319
465
  const attrLengthsBefore = this.snapshotAttrLengths(deviceState.deviceAttributes, pollRespMetadata);
320
466
  const timelineLenBefore = deviceState.deviceTimeline.timestampsUs.length;
321
- while (attrGroupPos < attrGroupStartPos + attrGroupDataLen) {
322
-
323
- // Add bounds checking
324
- if (attrGroupPos >= rxMsg.length) {
325
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} exceeds message length ${rxMsg.length}`);
326
- break;
327
- }
328
-
329
- const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, attrGroupPos,
330
- deviceState.deviceTimeline, pollRespMetadata,
331
- deviceState.deviceAttributes,
332
- this._maxDatapointsToStore);
467
+ const totalSamplesBefore = deviceState.deviceTimeline.totalSamplesAdded;
468
+ if (recordPayloadFormat === "lengthPrefixed") {
469
+ while (pollDataPos < samplesEndPos) {
333
470
 
334
- // console.log(`DevMan.handleClientMsgBinary decoded debugIdx ${debugMsgIndex} devType ${deviceState.deviceTypeInfo.name} attrGroupDataLen ${attrGroupDataLen} attrGroupPos ${attrGroupPos} sectionLen ${sectionLen} msgPos ${msgPos} rxMsgLen ${rxMsg.length} remainingLen ${remainingLen} pollRespMetadata ${JSON.stringify(pollRespMetadata)}`);
471
+ // Read sample length prefix
472
+ if (pollDataPos >= rxMsg.length) {
473
+ console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} exceeds message length ${rxMsg.length}`);
474
+ break;
475
+ }
476
+ const sampleLen = rxMsg[pollDataPos];
477
+ pollDataPos += 1;
335
478
 
336
- if (newMsgBufIdx < 0)
337
- {
338
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} processMsgAttrGroup failed newMsgBufIdx ${newMsgBufIdx}`);
339
- break;
340
- }
341
-
342
- // Prevent infinite loops
343
- if (newMsgBufIdx <= attrGroupPos) {
344
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} processMsgAttrGroup didn't advance position from ${attrGroupPos} to ${newMsgBufIdx}`);
345
- break;
346
- }
479
+ if (sampleLen === 0 || pollDataPos + sampleLen > samplesEndPos) {
480
+ break;
481
+ }
347
482
 
348
- attrGroupPos = newMsgBufIdx;
349
- deviceState.stateChanged = true;
483
+ const sampleStartPos = pollDataPos;
484
+ const sampleEndPos = pollDataPos + sampleLen;
485
+ const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, sampleStartPos,
486
+ deviceState.deviceTimeline, pollRespMetadata,
487
+ deviceState.deviceAttributes,
488
+ this._maxDatapointsToStore,
489
+ sampleEndPos);
350
490
 
351
- // console.log(`debugMsgTime ${debugMsgTime} newPt debugMsgIdx ${debugMsgIndex} rxMsgLen ${rxMsg.length} devType ${deviceState.deviceTypeInfo!.name} timestampsUs ${deviceState.deviceTimeline.timestampsUs[deviceState.deviceTimeline.timestampsUs.length - 1]} curTimelineLen ${deviceState.deviceTimeline.timestampsUs.length}`);
491
+ if (newMsgBufIdx < 0)
492
+ {
493
+ this.warnMalformedSample(
494
+ `${deviceKey}:${devTypeIdx}:lengthPrefixed`,
495
+ `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped malformed sample ` +
496
+ `device=${deviceKey} devTypeIdx=${devTypeIdx} sampleLen=${sampleLen} respBytes=${pollRespMetadata.b}`
497
+ );
498
+ pollDataPos += sampleLen;
499
+ continue;
500
+ }
352
501
 
502
+ // Advance by sampleLen regardless of how much processMsgAttrGroup consumed
503
+ pollDataPos += sampleLen;
504
+ deviceState.stateChanged = true;
505
+ }
506
+ } else {
507
+ const legacySampleLen = this.getLegacyRawSampleLen(pollRespMetadata);
508
+ if (legacySampleLen <= 0) {
509
+ this.warnMalformedSample(
510
+ `${deviceKey}:${devTypeIdx}:legacyRawLen`,
511
+ `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid legacy sample length ` +
512
+ `device=${deviceKey} devTypeIdx=${devTypeIdx} respBytes=${pollRespMetadata.b}`
513
+ );
514
+ } else {
515
+ while (pollDataPos + legacySampleLen <= samplesEndPos) {
516
+ const sampleStartPos = pollDataPos;
517
+ const sampleEndPos = pollDataPos + legacySampleLen;
518
+ const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, sampleStartPos,
519
+ deviceState.deviceTimeline, pollRespMetadata,
520
+ deviceState.deviceAttributes,
521
+ this._maxDatapointsToStore,
522
+ sampleEndPos);
523
+
524
+ if (newMsgBufIdx < 0)
525
+ {
526
+ this.warnMalformedSample(
527
+ `${deviceKey}:${devTypeIdx}:legacyRaw`,
528
+ `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped malformed legacy sample ` +
529
+ `device=${deviceKey} devTypeIdx=${devTypeIdx} sampleLen=${legacySampleLen} respBytes=${pollRespMetadata.b}`
530
+ );
531
+ pollDataPos += legacySampleLen;
532
+ continue;
533
+ }
534
+
535
+ pollDataPos += legacySampleLen;
536
+ deviceState.stateChanged = true;
537
+ }
353
538
 
354
- // console.log(`DevMan.handleClientMsgBinary group done debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} sectionLen ${sectionLen} msgPos ${msgPos} rxMsgLen ${rxMsg.length} remainingLen ${remainingLen}`);
539
+ if (pollDataPos < samplesEndPos) {
540
+ this.warnMalformedSample(
541
+ `${deviceKey}:${devTypeIdx}:legacyRawRemainder`,
542
+ `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped trailing legacy sample bytes ` +
543
+ `device=${deviceKey} devTypeIdx=${devTypeIdx} remaining=${samplesEndPos - pollDataPos} sampleLen=${legacySampleLen} respBytes=${pollRespMetadata.b}`
544
+ );
545
+ }
546
+ }
355
547
  }
356
548
 
357
549
  // Inform decoded-data callbacks
358
- this.emitDecodedData(deviceKey, busNum.toString(), devAddr.toString(), deviceState,
550
+ this.emitDecodedData(deviceKey, busNum.toString(), devAddrHex, deviceState,
359
551
  pollRespMetadata, attrLengthsBefore, timelineLenBefore);
552
+
553
+ const newSamples = deviceState.deviceTimeline.totalSamplesAdded - totalSamplesBefore;
554
+ this.updateDeviceStats(deviceKey, newSamples, Date.now());
360
555
  } else {
361
556
  console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} deviceState incomplete for device ${deviceKey}, skipping attribute processing`);
362
557
  }
363
558
 
364
559
  // Debug
365
- // console.log(`DevMan.handleClientMsgBinary section done debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} sectionLen ${sectionLen} msgPos ${msgPos} newMsgPos ${msgPos + sectionLengthLen + sectionLen} rxMsgLen ${rxMsg.length} remainingLen ${remainingLen}`);
560
+ // console.log(`DevMan.handleClientMsgBinary record done debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} recordLen ${recordLen} msgPos ${msgPos} newMsgPos ${msgPos + recordLenLen + recordLen} rxMsgLen ${rxMsg.length} remainingLen ${remainingLen}`);
366
561
 
367
- // Move to next message
368
- msgPos += sectionLengthLen + sectionLen;
562
+ // Advance past this record (recordLenLen + recordLen bytes)
563
+ msgPos += recordLenLen + recordLen;
369
564
  }
370
565
 
371
566
  // Check for devices that have not been updated for a while
@@ -374,6 +569,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
374
569
  Object.entries(this._deviceLastUpdateTime).forEach(([deviceKey, lastUpdateTime]) => {
375
570
  if ((nowTime - lastUpdateTime) > this._removeDevicesTimeMs) {
376
571
  delete this._devicesState[deviceKey];
572
+ delete this._deviceStats[deviceKey];
377
573
  }
378
574
  });
379
575
  }
@@ -394,6 +590,11 @@ export class DeviceManager implements RaftDeviceMgrIF{
394
590
  // Iterate over the buses
395
591
  Object.entries(data).forEach(([busName, devices]) => {
396
592
 
593
+ // Check the bus name doesn't start with _ which is reserved for non-device information such as topic name
594
+ if (busName.startsWith("_")) {
595
+ return;
596
+ }
597
+
397
598
  // Check for bus status info
398
599
  if (devices && typeof devices === "object" && "_s" in devices) {
399
600
  // console.log(`DeviceManager bus status ${JSON.stringify(devices._s)}`);
@@ -410,15 +611,20 @@ export class DeviceManager implements RaftDeviceMgrIF{
410
611
 
411
612
  // Device type name
412
613
  let deviceTypeName = "";
614
+ let deviceTypeIdx = -1;
413
615
  if (attrGroups && typeof attrGroups === 'object' && "_t" in attrGroups && typeof attrGroups._t === "string") {
414
616
  deviceTypeName = attrGroups._t || "";
415
- } else {
617
+ } else if (attrGroups && typeof attrGroups === 'object' && "_i" in attrGroups && typeof attrGroups._i === "number") {
618
+ deviceTypeIdx = attrGroups._i ?? -1;
619
+ deviceTypeName = deviceTypeIdx.toString();
620
+ } else
621
+ {
416
622
  console.warn(`DeviceManager missing device type attrGroups ${JSON.stringify(attrGroups)}`);
417
623
  return;
418
624
  }
419
625
 
420
626
  // Device key
421
- const deviceKey = getDeviceKey(busName, devAddr, deviceTypeName);
627
+ const deviceKey = getDeviceKey(busName, devAddr);
422
628
 
423
629
  // Update the last update time
424
630
  this._deviceLastUpdateTime[deviceKey] = Date.now();
@@ -444,12 +650,18 @@ export class DeviceManager implements RaftDeviceMgrIF{
444
650
  deviceTimeline: {
445
651
  timestampsUs: [],
446
652
  lastReportTimestampUs: 0,
447
- reportTimestampOffsetUs: 0
653
+ reportTimestampOffsetUs: 0,
654
+ totalSamplesAdded: 0,
655
+ emaLastSampleTimeUs: 0,
656
+ emaIntervalUs: 0,
657
+ emaPrevPollTimeUs: 0,
658
+ emaCalibrated: false,
659
+ emaCalibrationPolls: 0
448
660
  },
449
661
  deviceAttributes: {},
450
662
  deviceIsNew: true,
451
663
  stateChanged: false,
452
- isOnline: true,
664
+ onlineState: DeviceOnlineState.Online,
453
665
  deviceAddress: devAddr,
454
666
  deviceType: deviceTypeName,
455
667
  busName: busName
@@ -460,9 +672,15 @@ export class DeviceManager implements RaftDeviceMgrIF{
460
672
  // Get device state
461
673
  const deviceState = this._devicesState[deviceKey];
462
674
 
463
- // Check for online/offline state information
675
+ // Check for online/offline/pending-deletion state information
464
676
  if (attrGroups && typeof attrGroups === "object" && "_o" in attrGroups) {
465
- deviceState.isOnline = ((attrGroups._o === true) || (attrGroups._o === "1") || (attrGroups._o === 1));
677
+ const onlineStateVal = typeof attrGroups._o === 'number' ? attrGroups._o : parseInt(String(attrGroups._o), 10);
678
+ if (onlineStateVal === 2) {
679
+ // Pending deletion - remove device and skip further processing
680
+ this.removeDevice(deviceKey);
681
+ return;
682
+ }
683
+ deviceState.onlineState = onlineStateVal === 1 ? DeviceOnlineState.Online : DeviceOnlineState.Offline;
466
684
  }
467
685
 
468
686
  // Check if device type info is available
@@ -496,6 +714,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
496
714
 
497
715
  const attrLengthsBefore = this.snapshotAttrLengths(deviceState.deviceAttributes, pollRespMetadata);
498
716
  const timelineLenBefore = deviceState.deviceTimeline.timestampsUs.length;
717
+ const totalSamplesBefore = deviceState.deviceTimeline.totalSamplesAdded;
499
718
 
500
719
  // Loop
501
720
  while (msgBufIdx < msgBytes.length) {
@@ -512,6 +731,9 @@ export class DeviceManager implements RaftDeviceMgrIF{
512
731
 
513
732
  this.emitDecodedData(deviceKey, busName, devAddr, deviceState, pollRespMetadata,
514
733
  attrLengthsBefore, timelineLenBefore, attrGroupName, markers);
734
+
735
+ const newSamples = deviceState.deviceTimeline.totalSamplesAdded - totalSamplesBefore;
736
+ this.updateDeviceStats(deviceKey, newSamples, Date.now());
515
737
  });
516
738
  });
517
739
  });
@@ -522,6 +744,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
522
744
  Object.entries(this._deviceLastUpdateTime).forEach(([deviceKey, lastUpdateTime]) => {
523
745
  if ((nowTime - lastUpdateTime) > this._removeDevicesTimeMs) {
524
746
  delete this._devicesState[deviceKey];
747
+ delete this._deviceStats[deviceKey];
525
748
  }
526
749
  });
527
750
  }
@@ -559,6 +782,22 @@ export class DeviceManager implements RaftDeviceMgrIF{
559
782
  });
560
783
  }
561
784
 
785
+ ////////////////////////////////////////////////////////////////////////////
786
+ // Remove a device (e.g. on pending deletion)
787
+ ////////////////////////////////////////////////////////////////////////////
788
+
789
+ private removeDevice(deviceKey: string): void {
790
+ // Snapshot the state before removal for callbacks
791
+ const deviceState = this._devicesState[deviceKey];
792
+ if (deviceState) {
793
+ deviceState.onlineState = DeviceOnlineState.PendingDeletion;
794
+ this._deviceRemovedCallbacks.forEach((cb) => cb(deviceKey, deviceState));
795
+ }
796
+ delete this._devicesState[deviceKey];
797
+ delete this._deviceLastUpdateTime[deviceKey];
798
+ delete this._deviceStats[deviceKey];
799
+ }
800
+
562
801
  ////////////////////////////////////////////////////////////////////////////
563
802
  // Get device type info
564
803
  ////////////////////////////////////////////////////////////////////////////
@@ -637,6 +876,131 @@ export class DeviceManager implements RaftDeviceMgrIF{
637
876
  }
638
877
  }
639
878
 
879
+ private hasValidRecordAt(rxMsg: Uint8Array, msgPos: number, recordLenLen: number, recordHeaderLen: number): boolean {
880
+ if (msgPos === rxMsg.length) {
881
+ return true;
882
+ }
883
+ if (msgPos < 0 || msgPos > rxMsg.length) {
884
+ return false;
885
+ }
886
+ const remainingLen = rxMsg.length - msgPos;
887
+ if (remainingLen < recordLenLen + recordHeaderLen) {
888
+ return false;
889
+ }
890
+ const recordLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1];
891
+ return (recordLen >= recordHeaderLen) && (recordLen <= remainingLen - recordLenLen);
892
+ }
893
+
894
+ private resolveRecordPayloadFormat(rxMsg: Uint8Array, commonRecordHeaderEndPos: number, samplesEndPos: number,
895
+ pollRespMetadata: DeviceTypePollRespMetadata, preferredFormat: BinaryRecordPayloadFormat,
896
+ deviceSeqNumLen: number): BinaryRecordPayloadFormat {
897
+ const lengthPrefixedStartPos = commonRecordHeaderEndPos + deviceSeqNumLen;
898
+ const lengthPrefixedValid = this.areLengthPrefixedSamplesValid(rxMsg, lengthPrefixedStartPos, samplesEndPos, pollRespMetadata);
899
+ const legacySampleLen = this.getLegacyRawSampleLen(pollRespMetadata);
900
+ const legacyRawValid = this.areLegacyRawSamplesValid(commonRecordHeaderEndPos, samplesEndPos, legacySampleLen);
901
+
902
+ if (legacyRawValid && !lengthPrefixedValid) {
903
+ return "legacyRaw";
904
+ }
905
+ if (lengthPrefixedValid && !legacyRawValid) {
906
+ return "lengthPrefixed";
907
+ }
908
+ if (lengthPrefixedValid && legacyRawValid) {
909
+ return preferredFormat;
910
+ }
911
+ return preferredFormat;
912
+ }
913
+
914
+ private areLengthPrefixedSamplesValid(rxMsg: Uint8Array, pollDataPos: number, samplesEndPos: number,
915
+ pollRespMetadata: DeviceTypePollRespMetadata): boolean {
916
+ if ((pollDataPos < 0) || (pollDataPos > samplesEndPos) || (samplesEndPos > rxMsg.length)) {
917
+ return false;
918
+ }
919
+ if (pollDataPos === samplesEndPos) {
920
+ return true;
921
+ }
922
+
923
+ const fixedSampleLen = pollRespMetadata.c ? 0 : this.getLegacyRawSampleLen(pollRespMetadata);
924
+ let sampleCount = 0;
925
+ while (pollDataPos < samplesEndPos) {
926
+ const sampleLen = rxMsg[pollDataPos];
927
+ pollDataPos += 1;
928
+ if ((sampleLen === 0) || (pollDataPos + sampleLen > samplesEndPos)) {
929
+ return false;
930
+ }
931
+ if ((fixedSampleLen > 0) && (sampleLen !== fixedSampleLen)) {
932
+ return false;
933
+ }
934
+ pollDataPos += sampleLen;
935
+ sampleCount++;
936
+ }
937
+ return sampleCount > 0;
938
+ }
939
+
940
+ private areLegacyRawSamplesValid(pollDataPos: number, samplesEndPos: number, legacySampleLen: number): boolean {
941
+ if ((legacySampleLen <= 0) || (pollDataPos < 0) || (pollDataPos > samplesEndPos)) {
942
+ return false;
943
+ }
944
+ const payloadLen = samplesEndPos - pollDataPos;
945
+ return (payloadLen > 0) && (payloadLen % legacySampleLen === 0);
946
+ }
947
+
948
+ private getBinaryDeviceKey(busNum: number, devAddrHex: string, devTypeIdx: number, payloadFormat: BinaryRecordPayloadFormat): string {
949
+ const baseDeviceKey = getDeviceKey(busNum.toString(), devAddrHex);
950
+ if ((payloadFormat === "legacyRaw") && (busNum === 0) && (devAddrHex === "0")) {
951
+ return `${baseDeviceKey}_${devTypeIdx}`;
952
+ }
953
+ return baseDeviceKey;
954
+ }
955
+
956
+ private getLegacyRawSampleLen(pollRespMetadata: DeviceTypePollRespMetadata): number {
957
+ const legacyTimestampLen = 2;
958
+ return legacyTimestampLen + this.getPollRespPayloadSize(pollRespMetadata);
959
+ }
960
+
961
+ private getPollRespPayloadSize(pollRespMetadata: DeviceTypePollRespMetadata): number {
962
+ if (pollRespMetadata.c) {
963
+ return pollRespMetadata.b;
964
+ }
965
+
966
+ let attrPayloadLen = 0;
967
+ for (const attrDef of pollRespMetadata.a) {
968
+ if (!attrDef.t) {
969
+ return pollRespMetadata.b;
970
+ }
971
+ try {
972
+ attrPayloadLen += structSizeOf(attrDef.t);
973
+ } catch {
974
+ return pollRespMetadata.b;
975
+ }
976
+ }
977
+
978
+ // Cog v1.9.5 light metadata reports the direct-sensor payload size doubled,
979
+ // but the legacy raw record contains one fixed payload matching the attribute schema.
980
+ if ((attrPayloadLen > 0) && (pollRespMetadata.b > 0) && (attrPayloadLen <= pollRespMetadata.b)) {
981
+ return attrPayloadLen;
982
+ }
983
+ return pollRespMetadata.b;
984
+ }
985
+
986
+ private parseDeviceKeyForCommand(deviceKey: string): { bus: string; addr: string } {
987
+ const deviceState = this._devicesState[deviceKey];
988
+ if (deviceState) {
989
+ return { bus: deviceState.busName, addr: deviceState.deviceAddress };
990
+ }
991
+ return parseDeviceKey(deviceKey);
992
+ }
993
+
994
+ private warnMalformedSample(warningKey: string, message: string): void {
995
+ const nowMs = Date.now();
996
+ const lastWarnMs = this._malformedSampleWarnLastMs[warningKey] ?? 0;
997
+ if (nowMs - lastWarnMs < 5000) {
998
+ return;
999
+ }
1000
+ this._malformedSampleWarnLastMs[warningKey] = nowMs;
1001
+ console.warn(message);
1002
+ }
1003
+
640
1004
  ////////////////////////////////////////////////////////////////////////////
641
1005
  // Send action to device
642
1006
  ////////////////////////////////////////////////////////////////////////////
@@ -648,42 +1012,77 @@ export class DeviceManager implements RaftDeviceMgrIF{
648
1012
  }
649
1013
 
650
1014
  public async sendAction(deviceKey: string, action: DeviceTypeAction, data: number[]): Promise<boolean> {
651
- // console.log(`DeviceManager sendAction ${deviceKey} action name ${action.n} value ${value} prefix ${action.w}`);
652
-
653
- let writeBytes: Uint8Array;
1015
+ console.log(`DeviceManager sendAction ${deviceKey} action ${action.n} data ${data} map ${JSON.stringify(action.map)} keys ${action.map ? Object.keys(action.map) : 'none'}`);
654
1016
 
655
- // Check for one data item
656
- if (data.length === 1) {
1017
+ // For _conf.* actions, delegate to setSampleRate() which coordinates polling params
1018
+ if (action.n.startsWith('_conf.') && action.map && data.length === 1) {
1019
+ const result = await this.setSampleRate(deviceKey, data[0]);
1020
+ return result.ok;
1021
+ }
657
1022
 
658
- let value = data[0];
1023
+ let writeHexStr: string;
659
1024
 
660
- // Check for conversion
661
- if (action.sub !== undefined) {
662
- value = value - action.sub;
1025
+ // Check if action has a map - use mapped hex value directly
1026
+ if (action.map && data.length === 1) {
1027
+ const mapKey = String(data[0]);
1028
+ const mapEntry = action.map[mapKey];
1029
+ if (!mapEntry) {
1030
+ console.warn(`DeviceManager sendAction: no map entry for value ${mapKey} in action ${action.n}`);
1031
+ return false;
663
1032
  }
664
- if (action.mul !== undefined) {
665
- value = value * action.mul;
1033
+ const mappedHex = getActionMapHex(mapEntry);
1034
+ // Map values may contain &-separated multi-writes (e.g. "1048&114C&0a26")
1035
+ const writes = mappedHex.split('&');
1036
+ const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey);
1037
+ try {
1038
+ const msgHandler = this._systemUtils?.getMsgHandler();
1039
+ if (!msgHandler) return false;
1040
+ for (const hexWr of writes) {
1041
+ const cmd = "devman/cmdraw?bus=" + devBus + "&addr=" + devAddr + "&hexWr=" + hexWr;
1042
+ console.log(`DeviceManager sendAction ${action.n} ${cmd}`);
1043
+ const msgRslt = await msgHandler.sendRICRESTURL<RaftOKFail>(cmd);
1044
+ if (msgRslt.rslt !== "ok") return false;
1045
+ }
1046
+ return true;
1047
+ } catch (error) {
1048
+ console.warn(`DeviceManager sendAction error ${error}`);
1049
+ return false;
666
1050
  }
1051
+ } else {
1052
+ let writeBytes: Uint8Array;
667
1053
 
668
- // Form the write bytes
669
- writeBytes = action.t ? structPack(action.t, [value]) : new Uint8Array(0);
1054
+ // Check for one data item
1055
+ if (data.length === 1) {
670
1056
 
671
- } else
672
- {
1057
+ let value = data[0];
673
1058
 
674
- // Form the write bytes which may have multiple data items
675
- writeBytes = action.t ? structPack(action.t, data) : new Uint8Array(0);
676
- }
1059
+ // Check for conversion
1060
+ if (action.sub !== undefined) {
1061
+ value = value - action.sub;
1062
+ }
1063
+ if (action.mul !== undefined) {
1064
+ value = value * action.mul;
1065
+ }
677
1066
 
678
- // Convert to hex string
679
- let writeHexStr = this.toHex(writeBytes);
1067
+ // Form the write bytes
1068
+ writeBytes = action.t ? structPack(action.t, [value]) : new Uint8Array(0);
1069
+
1070
+ } else
1071
+ {
1072
+
1073
+ // Form the write bytes which may have multiple data items
1074
+ writeBytes = action.t ? structPack(action.t, data) : new Uint8Array(0);
1075
+ }
1076
+
1077
+ // Convert to hex string
1078
+ writeHexStr = this.toHex(writeBytes);
1079
+ }
680
1080
 
681
1081
  // Add prefix and postfix
682
1082
  writeHexStr = (action.w ? action.w : "") + writeHexStr + (action.wz ? action.wz : "");
683
1083
 
684
- // Separate the bus and address in the deviceKey (_ char)
685
- const devBus = deviceKey.split("_")[0]
686
- const devAddr = deviceKey.split("_")[1]
1084
+ // Parse the device key into bus and address components
1085
+ const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey);
687
1086
 
688
1087
  // Send the action to the server
689
1088
  const cmd = "devman/cmdraw?bus=" + devBus + "&addr=" + devAddr + "&hexWr=" + writeHexStr;
@@ -738,6 +1137,125 @@ export class DeviceManager implements RaftDeviceMgrIF{
738
1137
  return false;
739
1138
  }
740
1139
 
1140
+ ////////////////////////////////////////////////////////////////////////////
1141
+ // Set sample rate with coordinated polling parameters
1142
+ // Finds the closest supported rate from the device's _conf.rate action,
1143
+ // calculates optimal intervalUs and numSamples, and sends a single
1144
+ // /devman/devconfig call to set all parameters atomically.
1145
+ ////////////////////////////////////////////////////////////////////////////
1146
+
1147
+ public async setSampleRate(deviceKey: string, sampleRateHz: number, options?: {
1148
+ numSamples?: number;
1149
+ intervalUs?: number;
1150
+ maxNumSamples?: number;
1151
+ }): Promise<SampleRateResult> {
1152
+ // Look up device state and type info
1153
+ const deviceState = this._devicesState[deviceKey];
1154
+ if (!deviceState?.deviceTypeInfo) {
1155
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: 0, intervalUs: 0, numSamples: 0, error: 'Device not found or type info not loaded' };
1156
+ }
1157
+ const typeInfo = deviceState.deviceTypeInfo;
1158
+
1159
+ // Find the _conf.rate action
1160
+ const confRateAction = typeInfo.actions?.find(a => a.n === '_conf.rate');
1161
+ if (!confRateAction?.map) {
1162
+ // No _conf.rate action — use generic sample rate setting
1163
+ // Non-FIFO devices: poll once per sample period, 1 sample per read
1164
+ const samplePeriodUs = Math.round(1000000 / sampleRateHz);
1165
+ const numSamples = options?.numSamples ?? 1;
1166
+ const intervalUs = options?.intervalUs ?? Math.max(5000, samplePeriodUs);
1167
+
1168
+ const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey);
1169
+ const cmd = `devman/devconfig?bus=${devBus}&addr=${devAddr}&intervalUs=${intervalUs}&numSamples=${numSamples}`;
1170
+
1171
+ try {
1172
+ const msgHandler = this._systemUtils?.getMsgHandler();
1173
+ if (!msgHandler) {
1174
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: sampleRateHz, intervalUs, numSamples, error: 'No message handler available' };
1175
+ }
1176
+ const msgRslt = await msgHandler.sendRICRESTURL<RaftOKFail>(cmd);
1177
+ const ok = msgRslt.rslt === 'ok';
1178
+ return { ok, requestedRateHz: sampleRateHz, actualRateHz: sampleRateHz, intervalUs, numSamples, error: ok ? undefined : `Firmware returned: ${msgRslt.rslt}` };
1179
+ } catch (error) {
1180
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: sampleRateHz, intervalUs, numSamples, error: `${error}` };
1181
+ }
1182
+ }
1183
+
1184
+ // Find the closest supported rate from the map keys
1185
+ const supportedRates = Object.keys(confRateAction.map).map(Number).filter(r => !isNaN(r)).sort((a, b) => a - b);
1186
+ if (supportedRates.length === 0) {
1187
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: 0, intervalUs: 0, numSamples: 0, error: 'No valid rates in _conf.rate map' };
1188
+ }
1189
+
1190
+ let actualRate = supportedRates[0];
1191
+ let minDist = Math.abs(sampleRateHz - actualRate);
1192
+ for (const rate of supportedRates) {
1193
+ const dist = Math.abs(sampleRateHz - rate);
1194
+ if (dist < minDist) {
1195
+ minDist = dist;
1196
+ actualRate = rate;
1197
+ }
1198
+ }
1199
+
1200
+ // Look up map entry for the matched rate — may be object with recommended polling params
1201
+ const mapEntry = confRateAction.map[String(actualRate)];
1202
+ const mapObj = typeof mapEntry === 'object' ? mapEntry : null;
1203
+ const recommendedIntervalUs = mapObj?.i;
1204
+ const recommendedNumSamples = mapObj?.s;
1205
+
1206
+ // Calculate inter-sample period
1207
+ const samplePeriodUs = Math.round(1000000 / actualRate);
1208
+
1209
+ // Calculate optimal numSamples and intervalUs
1210
+ // Priority: explicit options > map entry recommendations > auto-calculation
1211
+ const maxNumSamples = options?.maxNumSamples ?? 20;
1212
+ let numSamples: number;
1213
+ let intervalUs: number;
1214
+
1215
+ if (options?.numSamples !== undefined && options?.intervalUs !== undefined) {
1216
+ // Both explicitly specified — use as-is
1217
+ numSamples = options.numSamples;
1218
+ intervalUs = options.intervalUs;
1219
+ } else if (options?.intervalUs !== undefined) {
1220
+ // intervalUs specified, derive numSamples from it
1221
+ intervalUs = options.intervalUs;
1222
+ numSamples = options?.numSamples ?? recommendedNumSamples ??
1223
+ Math.max(1, Math.min(maxNumSamples, Math.floor(intervalUs / samplePeriodUs)));
1224
+ } else if (options?.numSamples !== undefined) {
1225
+ // numSamples specified, derive intervalUs from it
1226
+ numSamples = options.numSamples;
1227
+ intervalUs = recommendedIntervalUs ??
1228
+ Math.round(numSamples * samplePeriodUs * 0.8);
1229
+ } else if (recommendedIntervalUs !== undefined && recommendedNumSamples !== undefined) {
1230
+ // Use map entry recommendations
1231
+ intervalUs = recommendedIntervalUs;
1232
+ numSamples = recommendedNumSamples;
1233
+ } else {
1234
+ // Auto-calculate: target ~50ms poll interval, bounded by sample rate
1235
+ const targetPollIntervalUs = 50000;
1236
+ numSamples = recommendedNumSamples ??
1237
+ Math.max(1, Math.min(maxNumSamples, Math.floor(targetPollIntervalUs / samplePeriodUs)));
1238
+ intervalUs = recommendedIntervalUs ??
1239
+ Math.max(5000, Math.min(1000000, Math.round(numSamples * samplePeriodUs * 0.8)));
1240
+ }
1241
+
1242
+ // Send single devconfig call with all parameters
1243
+ const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey);
1244
+ const cmd = `devman/devconfig?bus=${devBus}&addr=${devAddr}&sampleRateHz=${actualRate}&intervalUs=${intervalUs}&numSamples=${numSamples}`;
1245
+
1246
+ try {
1247
+ const msgHandler = this._systemUtils?.getMsgHandler();
1248
+ if (!msgHandler) {
1249
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: actualRate, intervalUs, numSamples, error: 'No message handler available' };
1250
+ }
1251
+ const msgRslt = await msgHandler.sendRICRESTURL<RaftOKFail>(cmd);
1252
+ const ok = msgRslt.rslt === 'ok';
1253
+ return { ok, requestedRateHz: sampleRateHz, actualRateHz: actualRate, intervalUs, numSamples, error: ok ? undefined : `Firmware returned: ${msgRslt.rslt}` };
1254
+ } catch (error) {
1255
+ return { ok: false, requestedRateHz: sampleRateHz, actualRateHz: actualRate, intervalUs, numSamples, error: `${error}` };
1256
+ }
1257
+ }
1258
+
741
1259
  ////////////////////////////////////////////////////////////////////////////
742
1260
  // Convert hex to bytes
743
1261
  ////////////////////////////////////////////////////////////////////////////
@@ -750,6 +1268,66 @@ export class DeviceManager implements RaftDeviceMgrIF{
750
1268
  return bytes;
751
1269
  }
752
1270
 
1271
+ ////////////////////////////////////////////////////////////////////////////
1272
+ // Helpers for device stats
1273
+ ////////////////////////////////////////////////////////////////////////////
1274
+
1275
+ private createEmptyStats(): DeviceStatsInternal {
1276
+ return {
1277
+ totalSamples: 0,
1278
+ windowMs: this._statsWindowMs,
1279
+ windowSamples: 0,
1280
+ sampleRateHz: 0,
1281
+ lastSampleTimeMs: null,
1282
+ lastUpdateTimeMs: null,
1283
+ windowEvents: []
1284
+ };
1285
+ }
1286
+
1287
+ private getOrCreateDeviceStats(deviceKey: string): DeviceStatsInternal {
1288
+ if (!this._deviceStats[deviceKey]) {
1289
+ this._deviceStats[deviceKey] = this.createEmptyStats();
1290
+ }
1291
+ return this._deviceStats[deviceKey];
1292
+ }
1293
+
1294
+ private cloneDeviceStats(stats: DeviceStatsInternal): DeviceStats {
1295
+ return {
1296
+ totalSamples: stats.totalSamples,
1297
+ windowMs: stats.windowMs,
1298
+ windowSamples: stats.windowSamples,
1299
+ sampleRateHz: stats.sampleRateHz,
1300
+ lastSampleTimeMs: stats.lastSampleTimeMs,
1301
+ lastUpdateTimeMs: stats.lastUpdateTimeMs
1302
+ };
1303
+ }
1304
+
1305
+ private updateDeviceStats(deviceKey: string, newSamples: number, nowMs: number): void {
1306
+ const stats = this.getOrCreateDeviceStats(deviceKey);
1307
+ stats.lastUpdateTimeMs = nowMs;
1308
+
1309
+ if (newSamples > 0) {
1310
+ stats.totalSamples += newSamples;
1311
+ stats.lastSampleTimeMs = nowMs;
1312
+ stats.windowEvents.push({ timeMs: nowMs, samples: newSamples });
1313
+ }
1314
+
1315
+ const windowStartMs = nowMs - stats.windowMs;
1316
+ while (stats.windowEvents.length > 0 && stats.windowEvents[0].timeMs < windowStartMs) {
1317
+ stats.windowEvents.shift();
1318
+ }
1319
+
1320
+ const windowSamples = stats.windowEvents.reduce((sum, entry) => sum + entry.samples, 0);
1321
+ stats.windowSamples = windowSamples;
1322
+ if (stats.windowEvents.length === 0) {
1323
+ stats.sampleRateHz = 0;
1324
+ return;
1325
+ }
1326
+
1327
+ const actualWindowMs = Math.max(1, nowMs - stats.windowEvents[0].timeMs);
1328
+ stats.sampleRateHz = (windowSamples * 1000) / actualWindowMs;
1329
+ }
1330
+
753
1331
  ////////////////////////////////////////////////////////////////////////////
754
1332
  // Helpers for decoded data callbacks
755
1333
  ////////////////////////////////////////////////////////////////////////////
@@ -760,7 +1338,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
760
1338
  return lengths;
761
1339
  }
762
1340
  pollRespMetadata.a.forEach((attr) => {
763
- lengths[attr.n] = deviceAttrs[attr.n]?.values.length || 0;
1341
+ lengths[attr.n] = deviceAttrs[attr.n]?.values.length ?? 0;
764
1342
  });
765
1343
  return lengths;
766
1344
  }
@@ -781,7 +1359,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
781
1359
  return;
782
1360
  }
783
1361
 
784
- const attrValues: Record<string, number[]> = {};
1362
+ const attrValues: Record<string, (number | string)[]> = {};
785
1363
  let hasValues = false;
786
1364
 
787
1365
  pollRespMetadata.a.forEach((attr) => {
@@ -789,7 +1367,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
789
1367
  if (!attrState) {
790
1368
  return;
791
1369
  }
792
- const prevLen = attrLengthsBefore[attr.n] || 0;
1370
+ const prevLen = attrLengthsBefore[attr.n] ?? 0;
793
1371
  if (attrState.values.length > prevLen) {
794
1372
  attrValues[attr.n] = attrState.values.slice(prevLen);
795
1373
  hasValues = hasValues || attrValues[attr.n].length > 0;