@robotical/raftjs 2.0.11 → 2.1.2

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 (163) 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 +10 -6
  14. package/dist/react-native/RaftChannelSimulated.js.map +1 -1
  15. package/dist/react-native/RaftChannelWebSerial.js +1 -1
  16. package/dist/react-native/RaftChannelWebSerial.js.map +1 -1
  17. package/dist/react-native/RaftChannelWebSocket.js +16 -1
  18. package/dist/react-native/RaftChannelWebSocket.js.map +1 -1
  19. package/dist/react-native/RaftConnector.d.ts +11 -1
  20. package/dist/react-native/RaftConnector.js +75 -9
  21. package/dist/react-native/RaftConnector.js.map +1 -1
  22. package/dist/react-native/RaftCustomAttrHandler.d.ts +2 -2
  23. package/dist/react-native/RaftCustomAttrHandler.js +32 -44
  24. package/dist/react-native/RaftCustomAttrHandler.js.map +1 -1
  25. package/dist/react-native/RaftDeviceInfo.d.ts +18 -0
  26. package/dist/react-native/RaftDeviceInfo.js +8 -0
  27. package/dist/react-native/RaftDeviceInfo.js.map +1 -1
  28. package/dist/react-native/RaftDeviceManager.d.ts +47 -2
  29. package/dist/react-native/RaftDeviceManager.js +696 -104
  30. package/dist/react-native/RaftDeviceManager.js.map +1 -1
  31. package/dist/react-native/RaftDeviceMgrIF.d.ts +11 -2
  32. package/dist/react-native/RaftDeviceStates.d.ts +27 -3
  33. package/dist/react-native/RaftDeviceStates.js +31 -6
  34. package/dist/react-native/RaftDeviceStates.js.map +1 -1
  35. package/dist/react-native/RaftFileHandler.d.ts +0 -1
  36. package/dist/react-native/RaftFileHandler.js +61 -23
  37. package/dist/react-native/RaftFileHandler.js.map +1 -1
  38. package/dist/react-native/RaftPublish.d.ts +2 -0
  39. package/dist/react-native/RaftPublish.js +81 -0
  40. package/dist/react-native/RaftPublish.js.map +1 -0
  41. package/dist/react-native/RaftStreamHandler.d.ts +11 -0
  42. package/dist/react-native/RaftStreamHandler.js +66 -0
  43. package/dist/react-native/RaftStreamHandler.js.map +1 -1
  44. package/dist/react-native/RaftStruct.d.ts +2 -2
  45. package/dist/react-native/RaftStruct.js +97 -26
  46. package/dist/react-native/RaftStruct.js.map +1 -1
  47. package/dist/react-native/RaftSystemType.d.ts +1 -0
  48. package/dist/react-native/RaftSystemUtils.d.ts +17 -1
  49. package/dist/react-native/RaftSystemUtils.js +51 -0
  50. package/dist/react-native/RaftSystemUtils.js.map +1 -1
  51. package/dist/react-native/RaftTimezone.d.ts +16 -0
  52. package/dist/react-native/RaftTimezone.js +153 -0
  53. package/dist/react-native/RaftTimezone.js.map +1 -0
  54. package/dist/react-native/RaftTypes.d.ts +27 -1
  55. package/dist/react-native/RaftTypes.js.map +1 -1
  56. package/dist/react-native/RaftUpdateManager.js +1 -1
  57. package/dist/react-native/RaftUpdateManager.js.map +1 -1
  58. package/dist/react-native/main.d.ts +3 -0
  59. package/dist/react-native/main.js +6 -1
  60. package/dist/react-native/main.js.map +1 -1
  61. package/dist/web/PseudocodeTranspiler.d.ts +6 -0
  62. package/dist/web/PseudocodeTranspiler.js +115 -0
  63. package/dist/web/PseudocodeTranspiler.js.map +1 -0
  64. package/dist/web/RaftAttributeHandler.d.ts +1 -1
  65. package/dist/web/RaftAttributeHandler.js +108 -32
  66. package/dist/web/RaftAttributeHandler.js.map +1 -1
  67. package/dist/web/RaftChannelBLE.web.d.ts +4 -0
  68. package/dist/web/RaftChannelBLE.web.js +59 -21
  69. package/dist/web/RaftChannelBLE.web.js.map +1 -1
  70. package/dist/web/RaftChannelSimulated.d.ts +1 -0
  71. package/dist/web/RaftChannelSimulated.js +10 -6
  72. package/dist/web/RaftChannelSimulated.js.map +1 -1
  73. package/dist/web/RaftChannelWebSerial.js +1 -1
  74. package/dist/web/RaftChannelWebSerial.js.map +1 -1
  75. package/dist/web/RaftChannelWebSocket.js +16 -1
  76. package/dist/web/RaftChannelWebSocket.js.map +1 -1
  77. package/dist/web/RaftConnector.d.ts +11 -1
  78. package/dist/web/RaftConnector.js +75 -9
  79. package/dist/web/RaftConnector.js.map +1 -1
  80. package/dist/web/RaftCustomAttrHandler.d.ts +2 -2
  81. package/dist/web/RaftCustomAttrHandler.js +32 -44
  82. package/dist/web/RaftCustomAttrHandler.js.map +1 -1
  83. package/dist/web/RaftDeviceInfo.d.ts +18 -0
  84. package/dist/web/RaftDeviceInfo.js +8 -0
  85. package/dist/web/RaftDeviceInfo.js.map +1 -1
  86. package/dist/web/RaftDeviceManager.d.ts +47 -2
  87. package/dist/web/RaftDeviceManager.js +696 -104
  88. package/dist/web/RaftDeviceManager.js.map +1 -1
  89. package/dist/web/RaftDeviceMgrIF.d.ts +11 -2
  90. package/dist/web/RaftDeviceStates.d.ts +27 -3
  91. package/dist/web/RaftDeviceStates.js +31 -6
  92. package/dist/web/RaftDeviceStates.js.map +1 -1
  93. package/dist/web/RaftFileHandler.d.ts +0 -1
  94. package/dist/web/RaftFileHandler.js +61 -23
  95. package/dist/web/RaftFileHandler.js.map +1 -1
  96. package/dist/web/RaftPublish.d.ts +2 -0
  97. package/dist/web/RaftPublish.js +81 -0
  98. package/dist/web/RaftPublish.js.map +1 -0
  99. package/dist/web/RaftStreamHandler.d.ts +11 -0
  100. package/dist/web/RaftStreamHandler.js +66 -0
  101. package/dist/web/RaftStreamHandler.js.map +1 -1
  102. package/dist/web/RaftStruct.d.ts +2 -2
  103. package/dist/web/RaftStruct.js +97 -26
  104. package/dist/web/RaftStruct.js.map +1 -1
  105. package/dist/web/RaftSystemType.d.ts +1 -0
  106. package/dist/web/RaftSystemUtils.d.ts +17 -1
  107. package/dist/web/RaftSystemUtils.js +51 -0
  108. package/dist/web/RaftSystemUtils.js.map +1 -1
  109. package/dist/web/RaftTimezone.d.ts +16 -0
  110. package/dist/web/RaftTimezone.js +153 -0
  111. package/dist/web/RaftTimezone.js.map +1 -0
  112. package/dist/web/RaftTypes.d.ts +27 -1
  113. package/dist/web/RaftTypes.js.map +1 -1
  114. package/dist/web/RaftUpdateManager.js +1 -1
  115. package/dist/web/RaftUpdateManager.js.map +1 -1
  116. package/dist/web/main.d.ts +3 -0
  117. package/dist/web/main.js +6 -1
  118. package/dist/web/main.js.map +1 -1
  119. package/examples/dashboard/package.json +2 -2
  120. package/examples/dashboard/src/DeviceActionsForm.tsx +158 -8
  121. package/examples/dashboard/src/DeviceLineChart.tsx +16 -3
  122. package/examples/dashboard/src/DevicePanel.tsx +92 -11
  123. package/examples/dashboard/src/DeviceSelectDialog.tsx +224 -0
  124. package/examples/dashboard/src/DeviceStatsPanel.tsx +76 -0
  125. package/examples/dashboard/src/DevicesPanel.tsx +11 -0
  126. package/examples/dashboard/src/LogConfigPanel.tsx +357 -0
  127. package/examples/dashboard/src/LogFilesPanel.tsx +200 -0
  128. package/examples/dashboard/src/LoggingPanel.tsx +264 -0
  129. package/examples/dashboard/src/Main.tsx +12 -2
  130. package/examples/dashboard/src/SettingsScreen.tsx +9 -4
  131. package/examples/dashboard/src/SystemTypeCog/CogStateInfo.ts +10 -3
  132. package/examples/dashboard/src/SystemTypeCog/SystemTypeCog.ts +37 -3
  133. package/examples/dashboard/src/SystemTypeGeneric/StateInfoGeneric.ts +10 -2
  134. package/examples/dashboard/src/SystemTypeGeneric/SystemTypeGeneric.ts +41 -7
  135. package/examples/dashboard/src/SystemTypeMarty/RICStateInfo.ts +34 -3
  136. package/examples/dashboard/src/styles.css +766 -1
  137. package/notes/web-ble-reconnect-retry.md +69 -0
  138. package/package.json +10 -7
  139. package/src/PseudocodeTranspiler.test.ts +372 -0
  140. package/src/PseudocodeTranspiler.ts +127 -0
  141. package/src/RaftAttributeHandler.ts +152 -76
  142. package/src/RaftChannelBLE.web.ts +62 -20
  143. package/src/RaftChannelSimulated.ts +11 -6
  144. package/src/RaftChannelWebSerial.ts +1 -1
  145. package/src/RaftChannelWebSocket.ts +16 -2
  146. package/src/RaftConnector.ts +93 -15
  147. package/src/RaftCustomAttrHandler.ts +35 -45
  148. package/src/RaftDeviceInfo.ts +27 -0
  149. package/src/RaftDeviceManager.test.ts +164 -0
  150. package/src/RaftDeviceManager.ts +823 -121
  151. package/src/RaftDeviceMgrIF.ts +13 -2
  152. package/src/RaftDeviceStates.ts +49 -8
  153. package/src/RaftFileHandler.ts +69 -28
  154. package/src/RaftPublish.ts +92 -0
  155. package/src/RaftStreamHandler.ts +84 -1
  156. package/src/RaftStruct.test.ts +229 -0
  157. package/src/RaftStruct.ts +101 -37
  158. package/src/RaftSystemType.ts +1 -0
  159. package/src/RaftSystemUtils.ts +59 -0
  160. package/src/RaftTimezone.ts +151 -0
  161. package/src/RaftTypes.ts +34 -1
  162. package/src/RaftUpdateManager.ts +1 -1
  163. package/src/main.ts +3 -0
@@ -7,16 +7,34 @@
7
7
  //
8
8
  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
9
9
 
10
- import { DeviceAttributeState, 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
+ export interface DeviceDecodedData {
21
+ deviceKey: string;
22
+ busName: string;
23
+ deviceAddress: string;
24
+ deviceType: string;
25
+ attrGroupName?: string;
26
+ attrValues: Record<string, (number | string)[]>;
27
+ timestampsUs: number[];
28
+ markers?: Record<string, unknown>;
29
+ fromOfflineBuffer?: boolean;
30
+ }
31
+
32
+ interface DeviceStatsInternal extends DeviceStats {
33
+ windowEvents: Array<{ timeMs: number; samples: number }>;
34
+ }
35
+
36
+ type BinaryRecordPayloadFormat = "lengthPrefixed" | "legacyRaw";
37
+
20
38
  export class DeviceManager implements RaftDeviceMgrIF{
21
39
 
22
40
  // Max data points to store
@@ -45,10 +63,17 @@ export class DeviceManager implements RaftDeviceMgrIF{
45
63
  private _newDeviceCallbacks: Array<(deviceKey: string, state: DeviceState) => void> = [];
46
64
  private _newDeviceAttributeCallbacks: Array<(deviceKey: string, attrState: DeviceAttributeState) => void> = [];
47
65
  private _newAttributeDataCallbacks: Array<(deviceKey: string, attrState: DeviceAttributeState) => void> = [];
66
+ private _decodedDataCallbacks: Array<(decoded: DeviceDecodedData) => void> = [];
67
+ private _deviceRemovedCallbacks: Array<(deviceKey: string, state: DeviceState) => void> = [];
48
68
 
49
69
  // Debug message index (to help debug with async messages)
50
70
  private _debugMsgIndex = 0;
51
71
 
72
+ // Device stats (sample counts, rates)
73
+ private _statsWindowMs = 5000;
74
+ private _deviceStats: { [deviceKey: string]: DeviceStatsInternal } = {};
75
+ private _malformedSampleWarnLastMs: { [warningKey: string]: number } = {};
76
+
52
77
  public getDevicesState(): DevicesState {
53
78
  return this._devicesState;
54
79
  }
@@ -57,6 +82,14 @@ export class DeviceManager implements RaftDeviceMgrIF{
57
82
  return this._devicesState[deviceKey];
58
83
  }
59
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
+
60
93
  // Cached device type data
61
94
  private _cachedDeviceTypeRecs: DeviceTypeInfoRecs = {};
62
95
 
@@ -146,6 +179,26 @@ export class DeviceManager implements RaftDeviceMgrIF{
146
179
  this._newAttributeDataCallbacks = this._newAttributeDataCallbacks.filter((cb) => cb !== callback);
147
180
  }
148
181
 
182
+ public addDecodedDataCallback(callback: (decoded: DeviceDecodedData) => void): void {
183
+ if (!this._decodedDataCallbacks.includes(callback)) {
184
+ this._decodedDataCallbacks.push(callback);
185
+ }
186
+ }
187
+
188
+ public removeDecodedDataCallback(callback: (decoded: DeviceDecodedData) => void): void {
189
+ this._decodedDataCallbacks = this._decodedDataCallbacks.filter((cb) => cb !== callback);
190
+ }
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
+
149
202
  ////////////////////////////////////////////////////////////////////////////
150
203
  // Set the friendly name for the device
151
204
  ////////////////////////////////////////////////////////////////////////////
@@ -162,93 +215,205 @@ export class DeviceManager implements RaftDeviceMgrIF{
162
215
  public async handleClientMsgBinary(rxMsg: Uint8Array) {
163
216
  // console.log(`DeviceManager client1 msg ${RaftUtils.bufferToHex(rxMsg)}`);
164
217
 
165
- // Example messages
166
- // 0080 0015 81 0000006a 0004 53b7 feff00000100081857079314 0011 80 00000000 0011 53b2 075106e400d60054 0010 80 00000000 0012 5231 000d0000010e01
167
- // 0080 0011 80 00000000 0002 4ae1 0787052606240007 000e 80 00000000 0003 0006 030001af01
168
- // 0080 0011 80 00000000 0002 e46e 061e05a206830433 0010 80000000000003e4760006030001c701
169
- // 0080 0010 81 00000015 0004 e4a2 0650fe00305002 0011 80000000000002e4a8061f059f06850438 001080000000000003e4aa0006030001c701
170
-
171
- // 0080 0011 80 00000000 0002 31e4 05ea05a506660137 000e 80 00000000 0003 0007 030001d901
172
-
173
- // 0080 0011 80 00000000 0002 4d63 0792053e06500061 000e 80 00000000 0003 0005 030001de01
174
-
175
- // First two bytes of each message are the message type (0080)
176
- // There are then a series of sections each of which is the data for a device
177
- // First two bytes of each section is the section length (big endian) not including the section length bytes themselves
178
- // 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
179
- // Next is the device address (4 bytes big endian)
180
- // Next is the device type index (2 bytes big endian)
181
- // 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
182
267
 
183
268
  // Debug
184
269
  // const debugMsgTime = Date.now();
185
270
  const debugMsgIndex = this._debugMsgIndex++;
186
271
 
187
- // Message layout
188
- const msgTypeLen = 2; // Length of the message type (first two bytes)
189
- const sectionLengthLen = 2; // Length of the inclusive section length (first two bytes of each section)
190
- const sectionConnectionModeLen = 1; // Length of the connection mode (next byte after section length)
191
- const sectionDeviceAddrLen = 4; // Length of the device address (next 4 bytes after connection mode)
192
- const sectionDeviceTypeIdxLen = 2; // Length of the device type index (next 2 bytes after device address)
193
- 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
194
285
 
195
286
  // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} rxMsg.length ${rxMsg.length} rxMsg ${RaftUtils.bufferToHex(rxMsg)}`);
196
287
 
197
288
  // Start after the message type
198
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
+ }
300
+
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
+ }
199
325
 
200
- // Iterate through sections
326
+ // Iterate through device records
201
327
  while (msgPos < rxMsg.length) {
202
328
 
203
- // Check length
329
+ // Check minimum length for record length prefix + record header
204
330
  const remainingLen = rxMsg.length - msgPos;
205
- if (remainingLen < sectionLengthLen + sectionHeaderLen) {
206
- 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}`);
207
333
  return;
208
334
  }
209
335
 
210
- // Get the length of the section
211
- const sectionLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1];
212
- if (sectionLen > remainingLen + sectionLengthLen) {
213
- 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}`);
214
340
  return;
215
341
  }
216
342
 
217
- // Extract message elements
218
- let sectionPos = msgPos + sectionLengthLen;
219
- const busNum = rxMsg[sectionPos] & 0x7f;
220
- const isOnline = (rxMsg[sectionPos] & 0x80) !== 0;
221
- sectionPos += sectionConnectionModeLen;
222
- // Get the device address and type index
223
- const devAddr = (rxMsg[sectionPos] << 24) + (rxMsg[sectionPos + 1] << 16) + (rxMsg[sectionPos + 2] << 8) + rxMsg[sectionPos + 3];
224
- sectionPos += sectionDeviceAddrLen;
225
- const devTypeIdx = (rxMsg[sectionPos] << 8) + rxMsg[sectionPos + 1];
226
- 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;
227
384
 
228
385
  // Debug
229
- // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} overallLen ${rxMsg.length} sectionPos ${msgPos} sectionLen ${sectionLen} ${attrGroupPos} ${RaftUtils.bufferToHex(rxMsg.slice(msgPos, msgPos + sectionLen))}`);
230
- // 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}`);
231
388
 
232
- // Device key
233
- 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);
234
392
 
235
393
  // Update the last update time
236
394
  this._deviceLastUpdateTime[deviceKey] = Date.now();
237
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
+
238
403
  // Check if a device state already exists
239
404
  if (!(deviceKey in this._devicesState) || (this._devicesState[deviceKey].deviceTypeInfo === undefined)) {
240
405
 
241
406
  // Get the device type info
242
- const deviceTypeInfo = await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString());
407
+ const deviceTypeInfo = resolvedDeviceTypeInfo ?? await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString());
243
408
 
244
409
  // Debug
245
- // 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)}`);
246
411
 
247
412
  // Handle case where device type info is not available
248
413
  if (deviceTypeInfo === undefined) {
249
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} deviceType ${devTypeIdx} info not available, skipping attribute processing for this section`);
250
- // Skip to next section without processing attributes
251
- 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;
252
417
  continue;
253
418
  }
254
419
 
@@ -258,7 +423,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
258
423
  this._devicesState[deviceKey].deviceTypeInfo = deviceTypeInfo;
259
424
  this._devicesState[deviceKey].deviceType = deviceTypeInfo.name || "";
260
425
  this._devicesState[deviceKey].busName = busNum.toString();
261
- this._devicesState[deviceKey].deviceAddress = devAddr.toString();
426
+ this._devicesState[deviceKey].deviceAddress = devAddrHex;
262
427
  }
263
428
  } else {
264
429
  // Create device record - device type info may be undefined
@@ -267,13 +432,19 @@ export class DeviceManager implements RaftDeviceMgrIF{
267
432
  deviceTimeline: {
268
433
  timestampsUs: [],
269
434
  lastReportTimestampUs: 0,
270
- reportTimestampOffsetUs: 0
435
+ reportTimestampOffsetUs: 0,
436
+ totalSamplesAdded: 0,
437
+ emaLastSampleTimeUs: 0,
438
+ emaIntervalUs: 0,
439
+ emaPrevPollTimeUs: 0,
440
+ emaCalibrated: false,
441
+ emaCalibrationPolls: 0
271
442
  },
272
443
  deviceAttributes: {},
273
444
  deviceIsNew: true,
274
445
  stateChanged: false,
275
- isOnline: true,
276
- deviceAddress: devAddr.toString(),
446
+ onlineState: DeviceOnlineState.Online,
447
+ deviceAddress: devAddrHex,
277
448
  deviceType: deviceTypeInfo?.name || "",
278
449
  busName: busNum.toString()
279
450
  };
@@ -282,7 +453,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
282
453
 
283
454
  // Get device state
284
455
  const deviceState = this._devicesState[deviceKey];
285
- deviceState.isOnline = isOnline;
456
+ deviceState.onlineState = isOnline ? DeviceOnlineState.Online : DeviceOnlineState.Offline;
286
457
 
287
458
  // Check if device type info is available and complete
288
459
  if (deviceState.deviceTypeInfo && deviceState.deviceTypeInfo.resp) {
@@ -290,53 +461,106 @@ export class DeviceManager implements RaftDeviceMgrIF{
290
461
  // Iterate over attributes in the group
291
462
  const pollRespMetadata = deviceState.deviceTypeInfo!.resp!;
292
463
 
293
- // Iterate over attribute groups
294
- const attrGroupDataLen = sectionLen - sectionHeaderLen;
295
- const attrGroupStartPos = attrGroupPos;
296
- while (attrGroupPos < attrGroupStartPos + attrGroupDataLen) {
464
+ // Process samples within this record
465
+ const attrLengthsBefore = this.snapshotAttrLengths(deviceState.deviceAttributes, pollRespMetadata);
466
+ const timelineLenBefore = deviceState.deviceTimeline.timestampsUs.length;
467
+ const totalSamplesBefore = deviceState.deviceTimeline.totalSamplesAdded;
468
+ if (recordPayloadFormat === "lengthPrefixed") {
469
+ while (pollDataPos < samplesEndPos) {
297
470
 
298
- // Add bounds checking
299
- if (attrGroupPos >= rxMsg.length) {
300
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} exceeds message length ${rxMsg.length}`);
301
- break;
302
- }
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;
303
478
 
304
- const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, attrGroupPos,
305
- deviceState.deviceTimeline, pollRespMetadata,
306
- deviceState.deviceAttributes,
307
- this._maxDatapointsToStore);
479
+ if (sampleLen === 0 || pollDataPos + sampleLen > samplesEndPos) {
480
+ break;
481
+ }
308
482
 
309
- // 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)}`);
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);
310
490
 
311
- if (newMsgBufIdx < 0)
312
- {
313
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} processMsgAttrGroup failed newMsgBufIdx ${newMsgBufIdx}`);
314
- break;
315
- }
316
-
317
- // Prevent infinite loops
318
- if (newMsgBufIdx <= attrGroupPos) {
319
- console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} processMsgAttrGroup didn't advance position from ${attrGroupPos} to ${newMsgBufIdx}`);
320
- break;
321
- }
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
+ }
322
501
 
323
- attrGroupPos = newMsgBufIdx;
324
- deviceState.stateChanged = true;
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
+ }
325
538
 
326
- // 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}`);
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
+ }
547
+ }
327
548
 
549
+ // Inform decoded-data callbacks
550
+ this.emitDecodedData(deviceKey, busNum.toString(), devAddrHex, deviceState,
551
+ pollRespMetadata, attrLengthsBefore, timelineLenBefore);
328
552
 
329
- // console.log(`DevMan.handleClientMsgBinary group done debugIdx ${debugMsgIndex} attrGroupPos ${attrGroupPos} sectionLen ${sectionLen} msgPos ${msgPos} rxMsgLen ${rxMsg.length} remainingLen ${remainingLen}`);
330
- }
553
+ const newSamples = deviceState.deviceTimeline.totalSamplesAdded - totalSamplesBefore;
554
+ this.updateDeviceStats(deviceKey, newSamples, Date.now());
331
555
  } else {
332
556
  console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} deviceState incomplete for device ${deviceKey}, skipping attribute processing`);
333
557
  }
334
558
 
335
559
  // Debug
336
- // 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}`);
337
561
 
338
- // Move to next message
339
- msgPos += sectionLengthLen + sectionLen;
562
+ // Advance past this record (recordLenLen + recordLen bytes)
563
+ msgPos += recordLenLen + recordLen;
340
564
  }
341
565
 
342
566
  // Check for devices that have not been updated for a while
@@ -345,6 +569,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
345
569
  Object.entries(this._deviceLastUpdateTime).forEach(([deviceKey, lastUpdateTime]) => {
346
570
  if ((nowTime - lastUpdateTime) > this._removeDevicesTimeMs) {
347
571
  delete this._devicesState[deviceKey];
572
+ delete this._deviceStats[deviceKey];
348
573
  }
349
574
  });
350
575
  }
@@ -365,6 +590,11 @@ export class DeviceManager implements RaftDeviceMgrIF{
365
590
  // Iterate over the buses
366
591
  Object.entries(data).forEach(([busName, devices]) => {
367
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
+
368
598
  // Check for bus status info
369
599
  if (devices && typeof devices === "object" && "_s" in devices) {
370
600
  // console.log(`DeviceManager bus status ${JSON.stringify(devices._s)}`);
@@ -381,15 +611,20 @@ export class DeviceManager implements RaftDeviceMgrIF{
381
611
 
382
612
  // Device type name
383
613
  let deviceTypeName = "";
614
+ let deviceTypeIdx = -1;
384
615
  if (attrGroups && typeof attrGroups === 'object' && "_t" in attrGroups && typeof attrGroups._t === "string") {
385
616
  deviceTypeName = attrGroups._t || "";
386
- } 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
+ {
387
622
  console.warn(`DeviceManager missing device type attrGroups ${JSON.stringify(attrGroups)}`);
388
623
  return;
389
624
  }
390
625
 
391
626
  // Device key
392
- const deviceKey = getDeviceKey(busName, devAddr, deviceTypeName);
627
+ const deviceKey = getDeviceKey(busName, devAddr);
393
628
 
394
629
  // Update the last update time
395
630
  this._deviceLastUpdateTime[deviceKey] = Date.now();
@@ -415,12 +650,18 @@ export class DeviceManager implements RaftDeviceMgrIF{
415
650
  deviceTimeline: {
416
651
  timestampsUs: [],
417
652
  lastReportTimestampUs: 0,
418
- reportTimestampOffsetUs: 0
653
+ reportTimestampOffsetUs: 0,
654
+ totalSamplesAdded: 0,
655
+ emaLastSampleTimeUs: 0,
656
+ emaIntervalUs: 0,
657
+ emaPrevPollTimeUs: 0,
658
+ emaCalibrated: false,
659
+ emaCalibrationPolls: 0
419
660
  },
420
661
  deviceAttributes: {},
421
662
  deviceIsNew: true,
422
663
  stateChanged: false,
423
- isOnline: true,
664
+ onlineState: DeviceOnlineState.Online,
424
665
  deviceAddress: devAddr,
425
666
  deviceType: deviceTypeName,
426
667
  busName: busName
@@ -431,9 +672,15 @@ export class DeviceManager implements RaftDeviceMgrIF{
431
672
  // Get device state
432
673
  const deviceState = this._devicesState[deviceKey];
433
674
 
434
- // Check for online/offline state information
675
+ // Check for online/offline/pending-deletion state information
435
676
  if (attrGroups && typeof attrGroups === "object" && "_o" in attrGroups) {
436
- 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;
437
684
  }
438
685
 
439
686
  // Check if device type info is available
@@ -441,6 +688,8 @@ export class DeviceManager implements RaftDeviceMgrIF{
441
688
  return;
442
689
  }
443
690
 
691
+ const markers = this.extractMarkers(attrGroups);
692
+
444
693
  // Iterate attribute groups
445
694
  Object.entries(attrGroups).forEach(([attrGroupName, msgHexStr]) => {
446
695
 
@@ -463,6 +712,10 @@ export class DeviceManager implements RaftDeviceMgrIF{
463
712
  // Iterate over attributes in the group
464
713
  const pollRespMetadata = deviceState.deviceTypeInfo!.resp!;
465
714
 
715
+ const attrLengthsBefore = this.snapshotAttrLengths(deviceState.deviceAttributes, pollRespMetadata);
716
+ const timelineLenBefore = deviceState.deviceTimeline.timestampsUs.length;
717
+ const totalSamplesBefore = deviceState.deviceTimeline.totalSamplesAdded;
718
+
466
719
  // Loop
467
720
  while (msgBufIdx < msgBytes.length) {
468
721
 
@@ -475,6 +728,12 @@ export class DeviceManager implements RaftDeviceMgrIF{
475
728
  msgBufIdx = newMsgBufIdx;
476
729
  deviceState.stateChanged = true;
477
730
  }
731
+
732
+ this.emitDecodedData(deviceKey, busName, devAddr, deviceState, pollRespMetadata,
733
+ attrLengthsBefore, timelineLenBefore, attrGroupName, markers);
734
+
735
+ const newSamples = deviceState.deviceTimeline.totalSamplesAdded - totalSamplesBefore;
736
+ this.updateDeviceStats(deviceKey, newSamples, Date.now());
478
737
  });
479
738
  });
480
739
  });
@@ -485,6 +744,7 @@ export class DeviceManager implements RaftDeviceMgrIF{
485
744
  Object.entries(this._deviceLastUpdateTime).forEach(([deviceKey, lastUpdateTime]) => {
486
745
  if ((nowTime - lastUpdateTime) > this._removeDevicesTimeMs) {
487
746
  delete this._devicesState[deviceKey];
747
+ delete this._deviceStats[deviceKey];
488
748
  }
489
749
  });
490
750
  }
@@ -522,6 +782,22 @@ export class DeviceManager implements RaftDeviceMgrIF{
522
782
  });
523
783
  }
524
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
+
525
801
  ////////////////////////////////////////////////////////////////////////////
526
802
  // Get device type info
527
803
  ////////////////////////////////////////////////////////////////////////////
@@ -600,6 +876,131 @@ export class DeviceManager implements RaftDeviceMgrIF{
600
876
  }
601
877
  }
602
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
+
603
1004
  ////////////////////////////////////////////////////////////////////////////
604
1005
  // Send action to device
605
1006
  ////////////////////////////////////////////////////////////////////////////
@@ -611,42 +1012,77 @@ export class DeviceManager implements RaftDeviceMgrIF{
611
1012
  }
612
1013
 
613
1014
  public async sendAction(deviceKey: string, action: DeviceTypeAction, data: number[]): Promise<boolean> {
614
- // console.log(`DeviceManager sendAction ${deviceKey} action name ${action.n} value ${value} prefix ${action.w}`);
1015
+ console.log(`DeviceManager sendAction ${deviceKey} action ${action.n} data ${data} map ${JSON.stringify(action.map)} keys ${action.map ? Object.keys(action.map) : 'none'}`);
615
1016
 
616
- let writeBytes: Uint8Array;
617
-
618
- // Check for one data item
619
- 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
+ }
620
1022
 
621
- let value = data[0];
1023
+ let writeHexStr: string;
622
1024
 
623
- // Check for conversion
624
- if (action.sub !== undefined) {
625
- 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;
626
1032
  }
627
- if (action.mul !== undefined) {
628
- 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;
629
1050
  }
1051
+ } else {
1052
+ let writeBytes: Uint8Array;
630
1053
 
631
- // Form the write bytes
632
- writeBytes = action.t ? structPack(action.t, [value]) : new Uint8Array(0);
1054
+ // Check for one data item
1055
+ if (data.length === 1) {
633
1056
 
634
- } else
635
- {
1057
+ let value = data[0];
636
1058
 
637
- // Form the write bytes which may have multiple data items
638
- writeBytes = action.t ? structPack(action.t, data) : new Uint8Array(0);
639
- }
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
+ }
1066
+
1067
+ // Form the write bytes
1068
+ writeBytes = action.t ? structPack(action.t, [value]) : new Uint8Array(0);
1069
+
1070
+ } else
1071
+ {
640
1072
 
641
- // Convert to hex string
642
- let writeHexStr = this.toHex(writeBytes);
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
+ }
643
1080
 
644
1081
  // Add prefix and postfix
645
1082
  writeHexStr = (action.w ? action.w : "") + writeHexStr + (action.wz ? action.wz : "");
646
1083
 
647
- // Separate the bus and address in the deviceKey (_ char)
648
- const devBus = deviceKey.split("_")[0]
649
- const devAddr = deviceKey.split("_")[1]
1084
+ // Parse the device key into bus and address components
1085
+ const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey);
650
1086
 
651
1087
  // Send the action to the server
652
1088
  const cmd = "devman/cmdraw?bus=" + devBus + "&addr=" + devAddr + "&hexWr=" + writeHexStr;
@@ -701,6 +1137,125 @@ export class DeviceManager implements RaftDeviceMgrIF{
701
1137
  return false;
702
1138
  }
703
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
+
704
1259
  ////////////////////////////////////////////////////////////////////////////
705
1260
  // Convert hex to bytes
706
1261
  ////////////////////////////////////////////////////////////////////////////
@@ -712,4 +1267,151 @@ export class DeviceManager implements RaftDeviceMgrIF{
712
1267
  }
713
1268
  return bytes;
714
1269
  }
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
+
1331
+ ////////////////////////////////////////////////////////////////////////////
1332
+ // Helpers for decoded data callbacks
1333
+ ////////////////////////////////////////////////////////////////////////////
1334
+
1335
+ private snapshotAttrLengths(deviceAttrs: DeviceAttributesState, pollRespMetadata: DeviceTypeInfo["resp"]): Record<string, number> {
1336
+ const lengths: Record<string, number> = {};
1337
+ if (!pollRespMetadata) {
1338
+ return lengths;
1339
+ }
1340
+ pollRespMetadata.a.forEach((attr) => {
1341
+ lengths[attr.n] = deviceAttrs[attr.n]?.values.length ?? 0;
1342
+ });
1343
+ return lengths;
1344
+ }
1345
+
1346
+ private emitDecodedData(
1347
+ deviceKey: string,
1348
+ busName: string,
1349
+ devAddr: string,
1350
+ deviceState: DeviceState,
1351
+ pollRespMetadata: DeviceTypeInfo["resp"],
1352
+ attrLengthsBefore: Record<string, number>,
1353
+ timelineLenBefore: number,
1354
+ attrGroupName = "",
1355
+ markers?: Record<string, unknown>,
1356
+ ): void {
1357
+
1358
+ if (!pollRespMetadata) {
1359
+ return;
1360
+ }
1361
+
1362
+ const attrValues: Record<string, (number | string)[]> = {};
1363
+ let hasValues = false;
1364
+
1365
+ pollRespMetadata.a.forEach((attr) => {
1366
+ const attrState = deviceState.deviceAttributes[attr.n];
1367
+ if (!attrState) {
1368
+ return;
1369
+ }
1370
+ const prevLen = attrLengthsBefore[attr.n] ?? 0;
1371
+ if (attrState.values.length > prevLen) {
1372
+ attrValues[attr.n] = attrState.values.slice(prevLen);
1373
+ hasValues = hasValues || attrValues[attr.n].length > 0;
1374
+ }
1375
+ });
1376
+
1377
+ if (!hasValues) {
1378
+ return;
1379
+ }
1380
+
1381
+ const timestampsUs = deviceState.deviceTimeline.timestampsUs.slice(timelineLenBefore);
1382
+
1383
+ const decoded: DeviceDecodedData = {
1384
+ deviceKey,
1385
+ busName,
1386
+ deviceAddress: devAddr,
1387
+ deviceType: deviceState.deviceType,
1388
+ attrGroupName: attrGroupName || undefined,
1389
+ attrValues,
1390
+ timestampsUs,
1391
+ };
1392
+
1393
+ if (markers && Object.keys(markers).length > 0) {
1394
+ decoded.markers = markers;
1395
+ decoded.fromOfflineBuffer = this.isTruthy(markers["_buf"]);
1396
+ }
1397
+
1398
+ this._decodedDataCallbacks.forEach((cb) => cb(decoded));
1399
+ }
1400
+
1401
+ private extractMarkers(attrGroups: any): Record<string, unknown> {
1402
+ const markers: Record<string, unknown> = {};
1403
+ if (!attrGroups || typeof attrGroups !== "object") {
1404
+ return markers;
1405
+ }
1406
+ Object.entries(attrGroups).forEach(([key, value]) => {
1407
+ if (key.startsWith("_") && key !== "_t" && key !== "_o") {
1408
+ markers[key] = value;
1409
+ }
1410
+ });
1411
+ return markers;
1412
+ }
1413
+
1414
+ private isTruthy(val: unknown): boolean {
1415
+ return val === true || val === 1 || val === "1";
1416
+ }
715
1417
  }