@robotical/raftjs 2.1.3 → 2.1.4

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 (30) hide show
  1. package/devdocs/decode-overrun-investigation.md +167 -0
  2. package/devdocs/message-panel-design.md +320 -0
  3. package/dist/react-native/RaftAttributeHandler.d.ts +10 -1
  4. package/dist/react-native/RaftAttributeHandler.js +60 -8
  5. package/dist/react-native/RaftAttributeHandler.js.map +1 -1
  6. package/dist/react-native/RaftChannelSimulated.d.ts +1 -0
  7. package/dist/react-native/RaftChannelSimulated.js +67 -0
  8. package/dist/react-native/RaftChannelSimulated.js.map +1 -1
  9. package/dist/react-native/RaftDeviceInfo.d.ts +5 -1
  10. package/dist/react-native/RaftDeviceManager.js +75 -32
  11. package/dist/react-native/RaftDeviceManager.js.map +1 -1
  12. package/dist/web/RaftAttributeHandler.d.ts +10 -1
  13. package/dist/web/RaftAttributeHandler.js +60 -8
  14. package/dist/web/RaftAttributeHandler.js.map +1 -1
  15. package/dist/web/RaftChannelSimulated.d.ts +1 -0
  16. package/dist/web/RaftChannelSimulated.js +67 -0
  17. package/dist/web/RaftChannelSimulated.js.map +1 -1
  18. package/dist/web/RaftDeviceInfo.d.ts +5 -1
  19. package/dist/web/RaftDeviceManager.js +75 -32
  20. package/dist/web/RaftDeviceManager.js.map +1 -1
  21. package/examples/dashboard/src/DeviceActionsForm.tsx +3 -0
  22. package/examples/dashboard/src/DeviceLineChart.tsx +5 -4
  23. package/examples/dashboard/src/DevicePanel.tsx +3 -0
  24. package/package.json +1 -1
  25. package/src/RaftAttributeHandler.ts +89 -9
  26. package/src/RaftChannelSimulated.test.ts +62 -0
  27. package/src/RaftChannelSimulated.ts +91 -0
  28. package/src/RaftDeviceInfo.ts +5 -1
  29. package/src/RaftDeviceManager.test.ts +35 -1
  30. package/src/RaftDeviceManager.ts +86 -33
@@ -148,10 +148,11 @@ const DeviceLineChart: React.FC<DeviceLineChartProps> = memo(({ deviceKey, lastU
148
148
  min: axis.range[0],
149
149
  max: axis.range[1],
150
150
  callback: (value: number) => {
151
- if (typeof value === 'number' && Math.abs(value) > 0 && Math.abs(value) < 1) {
152
- return value.toPrecision(2);
153
- }
154
- return value;
151
+ if (typeof value !== 'number') return value;
152
+ if (value === 0) return '0';
153
+ const abs = Math.abs(value);
154
+ if (abs < 1) return Number(value.toPrecision(2)).toString();
155
+ return Number(value.toPrecision(6)).toString();
155
156
  },
156
157
  },
157
158
  };
@@ -173,6 +173,9 @@ const DevicePanel = ({ deviceKey, lastUpdated }: DevicePanelProps) => {
173
173
  if (bracketsAdded) {
174
174
  headerText += `)`;
175
175
  }
176
+ if (deviceState?.deviceTypeInfo?.role?.toLowerCase() === "system") {
177
+ headerText += " System";
178
+ }
176
179
  if (deviceState?.onlineState !== DeviceOnlineState.Online) {
177
180
  headerText += " (Offline)";
178
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robotical/raftjs",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "Javascript/TS library for Raft library",
5
5
  "main": "dist/web/main.js",
6
6
  "types": "dist/web/main.d.ts",
@@ -11,6 +11,23 @@ import CustomAttrHandler from "./RaftCustomAttrHandler";
11
11
  import { DeviceTypeAttribute, DeviceTypePollRespMetadata, decodeAttrUnitsEncoding, isAttrTypeSigned } from "./RaftDeviceInfo";
12
12
  import { DeviceAttributesState, DeviceTimeline } from "./RaftDeviceStates";
13
13
  import { structSizeOf, structUnpack } from "./RaftStruct";
14
+ import RaftUtils from "./RaftUtils";
15
+
16
+ // Optional diagnostic context for attribute decoding. When provided, the
17
+ // handler emits a detailed warning if an attribute would read past the
18
+ // declared sample boundary or the end of the message buffer. This is used to
19
+ // track down firmware/schema mismatches.
20
+ export interface AttrDecodeDiagContext {
21
+ deviceKey?: string;
22
+ deviceType?: string;
23
+ debugMsgIndex?: number;
24
+ // Inclusive start (in msgBuffer) of the current sample's bytes (after the
25
+ // 1-byte sampleLen prefix). When provided together with sampleEndIdx, the
26
+ // handler will bound-check attribute reads against this range.
27
+ sampleStartIdx?: number;
28
+ // Exclusive end (in msgBuffer) of the current sample's bytes.
29
+ sampleEndIdx?: number;
30
+ }
14
31
 
15
32
  export default class AttributeHandler {
16
33
 
@@ -23,8 +40,18 @@ export default class AttributeHandler {
23
40
  private POLL_RESULT_RESOLUTION_US = 100;
24
41
 
25
42
  public processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata,
26
- devAttrsState: DeviceAttributesState, maxDataPoints: number, msgEndIdx = msgBuffer.length): number {
27
-
43
+ devAttrsState: DeviceAttributesState, maxDataPoints: number,
44
+ msgEndIdxOrDiagCtx: number | AttrDecodeDiagContext = msgBuffer.length,
45
+ diagCtx?: AttrDecodeDiagContext): number {
46
+
47
+ // Merge rationale: Robotical's devbin compatibility parser needs an
48
+ // explicit sample boundary; upstream's diagnostics need per-sample
49
+ // context. Accept both call styles so malformed samples are skipped
50
+ // without losing useful overrun warnings.
51
+ const msgEndIdx = typeof msgEndIdxOrDiagCtx === "number"
52
+ ? msgEndIdxOrDiagCtx
53
+ : msgEndIdxOrDiagCtx.sampleEndIdx ?? msgBuffer.length;
54
+ const effectiveDiagCtx = typeof msgEndIdxOrDiagCtx === "number" ? diagCtx : msgEndIdxOrDiagCtx;
28
55
  // console.log(`processMsgAttrGroup msg ${msgHexStr} timestamp ${timestamp} origTimestamp ${origTimestamp} msgBufIdx ${msgBufIdx}`)
29
56
  const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length);
30
57
 
@@ -39,6 +66,11 @@ export default class AttributeHandler {
39
66
 
40
67
  // New attribute values (in order as they appear in the attributes JSON)
41
68
  let newAttrValues: (number | string)[][] = [];
69
+ // Tracks whether any individual attribute decode failed in the non-custom path.
70
+ // When true, the detailed per-attribute overrun warning has already been emitted
71
+ // by processMsgAttribute, so we suppress the redundant downstream length/empty
72
+ // warnings that would otherwise fire every poll.
73
+ let attrDecodeFailed = false;
42
74
  if ("c" in pollRespMetadata) {
43
75
 
44
76
  // Extract attribute values using custom handler
@@ -83,13 +115,15 @@ export default class AttributeHandler {
83
115
  if (!("t" in attrDef)) {
84
116
  console.warn(`DeviceManager msg unknown msgBuffer ${msgBuffer} tsUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`);
85
117
  newAttrValues.push([]);
118
+ attrDecodeFailed = true;
86
119
  continue;
87
120
  }
88
121
 
89
122
  // console.log(`RaftAttrHdlr.processMsgAttrGroup attr ${attrDef.n} msgBufIdx ${msgBufIdx} timestampUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`);
90
123
 
91
124
  // Process the attribute
92
- const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, boundedMsgEndIdx);
125
+ const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx,
126
+ boundedMsgEndIdx, pollRespMetadata, effectiveDiagCtx);
93
127
  if (newMsgBufIdx < 0) {
94
128
  return -1;
95
129
  }
@@ -114,14 +148,18 @@ export default class AttributeHandler {
114
148
  const numNewDataPoints = newAttrValues[0].length;
115
149
  for (let i = 1; i < newAttrValues.length; i++) {
116
150
  if (newAttrValues[i].length !== numNewDataPoints) {
117
- console.warn(`DeviceManager msg attrGroup ${pollRespMetadata} attrName ${pollRespMetadata.a[i].n} newAttrValues ${newAttrValues} do not have the same length`);
151
+ if (!attrDecodeFailed) {
152
+ console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} attrName ${pollRespMetadata.a[i].n} newAttrValues lengths ${newAttrValues.map(v => v.length).join(",")} do not match`);
153
+ }
118
154
  return msgDataStartIdx+pollRespSizeBytes;
119
155
  }
120
156
  }
121
157
 
122
158
  // All attributes in the schema should have values
123
159
  if (newAttrValues.length !== pollRespMetadata.a.length) {
124
- console.warn(`DeviceManager msg attrGroup ${pollRespMetadata} newAttrValues ${newAttrValues} length does not match attrGroup.a length`);
160
+ if (!attrDecodeFailed) {
161
+ console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} newAttrValues length ${newAttrValues.length} does not match attrGroup.a length ${pollRespMetadata.a.length}`);
162
+ }
125
163
  return msgDataStartIdx+pollRespSizeBytes;
126
164
  }
127
165
 
@@ -272,7 +310,9 @@ export default class AttributeHandler {
272
310
  }
273
311
  }
274
312
 
275
- private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number, msgEndIdx: number): { values: (number | string)[], newMsgBufIdx: number} {
313
+ private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number,
314
+ msgEndIdx: number, pollRespMetadata?: DeviceTypePollRespMetadata,
315
+ diagCtx?: AttrDecodeDiagContext): { values: (number | string)[], newMsgBufIdx: number} {
276
316
 
277
317
  // Current field message string index
278
318
  let curFieldBufIdx = msgBufIdx;
@@ -295,7 +335,9 @@ export default class AttributeHandler {
295
335
  // Copy bytes from the specified positions
296
336
  for (let i = 0; i < attrDef.at.length && i < elemSize; i++) {
297
337
  const sourceIdx = msgDataStartIdx + attrDef.at[i];
298
- if (sourceIdx >= boundedMsgEndIdx) {
338
+ if (sourceIdx < msgDataStartIdx || sourceIdx >= boundedMsgEndIdx) {
339
+ this.warnAttrOverrun(attrDef, msgBuffer, sourceIdx, elemSize, msgDataStartIdx,
340
+ true, pollRespMetadata, diagCtx, sourceIdx >= msgBuffer.length ? "msgBuffer" : "sample");
299
341
  return { values: [], newMsgBufIdx: -1 };
300
342
  }
301
343
  bytesForType[i] = msgBuffer[sourceIdx];
@@ -312,11 +354,15 @@ export default class AttributeHandler {
312
354
  attrUsesAbsPos = true;
313
355
  }
314
356
 
315
- // Check if outside bounds of message
357
+ // Merge rationale: keep Robotical's hard sample bounds as the source of
358
+ // truth, but emit upstream's one-shot diagnostic when a schema tries to
359
+ // read beyond the sample or buffer.
316
360
  const attrEndIdx = curFieldBufIdx + numBytesConsumed;
317
361
  const effectiveMsgEndIdx = Math.min(Math.max(msgEndIdx, curFieldBufIdx), msgBuffer.length);
318
362
  if (curFieldBufIdx >= effectiveMsgEndIdx || attrEndIdx > effectiveMsgEndIdx) {
319
- // console.warn(`DeviceManager msg outside bounds msgBuffer ${msgBuffer} attrName ${attrDef.n}`);
363
+ this.warnAttrOverrun(attrDef, msgBuffer, curFieldBufIdx, numBytesConsumed, msgDataStartIdx,
364
+ attrUsesAbsPos, pollRespMetadata, diagCtx,
365
+ attrEndIdx > msgBuffer.length ? "msgBuffer" : "sample");
320
366
  return { values: [], newMsgBufIdx: -1 };
321
367
  }
322
368
 
@@ -439,6 +485,40 @@ export default class AttributeHandler {
439
485
  return { values: attrValues, newMsgBufIdx: msgBufIdx };
440
486
  }
441
487
 
488
+ // One-shot detailed warning when an attribute decode would overrun the
489
+ // sample/message bounds. Includes the exact bytes and schema so the
490
+ // firmware vs. registered device-type schema can be reconciled.
491
+ private _overrunWarnSeen: Set<string> = new Set();
492
+ private warnAttrOverrun(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, curFieldBufIdx: number,
493
+ attrTypeSize: number, msgDataStartIdx: number, attrUsesAbsPos: boolean,
494
+ pollRespMetadata: DeviceTypePollRespMetadata | undefined,
495
+ diagCtx: AttrDecodeDiagContext | undefined, overrunOf: "msgBuffer" | "sample"): void {
496
+ const sampleStart = diagCtx?.sampleStartIdx ?? msgDataStartIdx;
497
+ const sampleEnd = diagCtx?.sampleEndIdx;
498
+ const dedupeKey = `${diagCtx?.deviceKey ?? "?"}|${diagCtx?.deviceType ?? "?"}|${attrDef.n}|${attrDef.t}|${attrUsesAbsPos ? attrDef.at : "rel"}`;
499
+ if (this._overrunWarnSeen.has(dedupeKey)) {
500
+ return;
501
+ }
502
+ this._overrunWarnSeen.add(dedupeKey);
503
+ const sampleHex = sampleEnd !== undefined
504
+ ? RaftUtils.bufferToHex(msgBuffer.slice(sampleStart, sampleEnd))
505
+ : "<unknown sample bounds>";
506
+ const availableInSample = sampleEnd !== undefined ? Math.max(0, sampleEnd - curFieldBufIdx) : -1;
507
+ const availableInBuffer = Math.max(0, msgBuffer.length - curFieldBufIdx);
508
+ console.warn(
509
+ `AttributeHandler decode overrun (${overrunOf}): ` +
510
+ `deviceKey=${diagCtx?.deviceKey ?? "?"} deviceType=${diagCtx?.deviceType ?? "?"} ` +
511
+ `debugMsgIndex=${diagCtx?.debugMsgIndex ?? "?"} attr.n=${attrDef.n} attr.t=${attrDef.t} ` +
512
+ `attrTypeSize=${attrTypeSize} attrUsesAbsPos=${attrUsesAbsPos} attr.at=${JSON.stringify(attrDef.at)} ` +
513
+ `curFieldBufIdx=${curFieldBufIdx} msgBuffer.length=${msgBuffer.length} ` +
514
+ `sampleStartIdx=${sampleStart} sampleEndIdx=${sampleEnd ?? "?"} ` +
515
+ `availableInSample=${availableInSample} availableInBuffer=${availableInBuffer} ` +
516
+ `sampleHex=${sampleHex} ` +
517
+ `pollRespMetadata.b=${pollRespMetadata?.b} ` +
518
+ `schema=${JSON.stringify(pollRespMetadata?.a)}`
519
+ );
520
+ }
521
+
442
522
  private signExtend(value: number, mask: number): number {
443
523
  const signBitMask = (mask + 1) >> 1;
444
524
  const signBit = value & signBitMask;
@@ -0,0 +1,62 @@
1
+ import AttributeHandler from "./RaftAttributeHandler";
2
+ import RaftChannelSimulated from "./RaftChannelSimulated";
3
+ import { DeviceAttributesState, DeviceTimeline } from "./RaftDeviceStates";
4
+
5
+ function hexToBytes(hex: string): Uint8Array {
6
+ const bytes = new Uint8Array(hex.length / 2);
7
+ for (let idx = 0; idx < bytes.length; idx++) {
8
+ bytes[idx] = parseInt(hex.slice(idx * 2, idx * 2 + 2), 16);
9
+ }
10
+ return bytes;
11
+ }
12
+
13
+ function createTimeline(): DeviceTimeline {
14
+ return {
15
+ timestampsUs: [],
16
+ lastReportTimestampUs: 0,
17
+ reportTimestampOffsetUs: 0,
18
+ totalSamplesAdded: 0,
19
+ emaLastSampleTimeUs: 0,
20
+ emaIntervalUs: 0,
21
+ emaPrevPollTimeUs: 0,
22
+ emaCalibrated: false,
23
+ emaCalibrationPolls: 0,
24
+ };
25
+ }
26
+
27
+ describe("RaftChannelSimulated", () => {
28
+ test("generates decodable SCD30 CO2, temperature and humidity data", () => {
29
+ const channel = new RaftChannelSimulated() as any;
30
+ const deviceTypeInfo = channel._deviceTypeInfo.SCD30;
31
+ expect(deviceTypeInfo).toBeDefined();
32
+
33
+ const msg = channel._createSimulatedDeviceInfoMsg(1000, "SCD30", deviceTypeInfo, 2500) as Uint8Array;
34
+ const publishedJson = JSON.parse(new TextDecoder().decode(msg.slice(2)));
35
+ const payload = hexToBytes(publishedJson["1"].SCD30.pub);
36
+
37
+ const attrs: DeviceAttributesState = {};
38
+ const timeline = createTimeline();
39
+ const handler = new AttributeHandler();
40
+
41
+ const nextIdx = handler.processMsgAttrGroup(payload, 0, timeline, deviceTypeInfo.resp, attrs, 100);
42
+ expect(nextIdx).toBe(26);
43
+
44
+ expect(attrs.CO2.values).toHaveLength(1);
45
+ expect(attrs.temperature.values).toHaveLength(1);
46
+ expect(attrs.humidity.values).toHaveLength(1);
47
+
48
+ const co2 = attrs.CO2.values[0] as number;
49
+ const temperature = attrs.temperature.values[0] as number;
50
+ const humidity = attrs.humidity.values[0] as number;
51
+
52
+ expect(Number.isFinite(co2)).toBe(true);
53
+ expect(Number.isFinite(temperature)).toBe(true);
54
+ expect(Number.isFinite(humidity)).toBe(true);
55
+ expect(co2).toBeGreaterThanOrEqual(400);
56
+ expect(co2).toBeLessThanOrEqual(2000);
57
+ expect(temperature).toBeGreaterThanOrEqual(-40);
58
+ expect(temperature).toBeLessThanOrEqual(125);
59
+ expect(humidity).toBeGreaterThanOrEqual(0);
60
+ expect(humidity).toBeLessThanOrEqual(100);
61
+ });
62
+ });
@@ -586,6 +586,34 @@ export default class RaftChannelSimulated implements RaftChannel {
586
586
 
587
587
  return true;
588
588
  }
589
+ case "SCD30": {
590
+ if (dataBlockSizeBytes < 24) {
591
+ return false;
592
+ }
593
+
594
+ const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
595
+ const co2Ppm = clamp(
596
+ 850 + 320 * Math.sin(deviceTimeMs / 6500) + 45 * Math.sin(deviceTimeMs / 1400),
597
+ 400,
598
+ 2000
599
+ );
600
+ const temperatureC = clamp(
601
+ 22 + 2.2 * Math.sin(deviceTimeMs / 11000 + Math.PI / 5),
602
+ -40,
603
+ 125
604
+ );
605
+ const humidityPercent = clamp(
606
+ 48 + 14 * Math.sin(deviceTimeMs / 9000 + Math.PI / 2),
607
+ 0,
608
+ 100
609
+ );
610
+
611
+ return (
612
+ this._writeFloat32BytesAtOffsets(dataView, bytePos, [6, 7, 9, 10], co2Ppm) &&
613
+ this._writeFloat32BytesAtOffsets(dataView, bytePos, [12, 13, 15, 16], temperatureC) &&
614
+ this._writeFloat32BytesAtOffsets(dataView, bytePos, [18, 19, 21, 22], humidityPercent)
615
+ );
616
+ }
589
617
  case "RoboticalLEDRing": {
590
618
  if (dataBlockSizeBytes < 1) {
591
619
  return false;
@@ -665,6 +693,30 @@ export default class RaftChannelSimulated implements RaftChannel {
665
693
  }
666
694
  }
667
695
 
696
+ private _writeFloat32BytesAtOffsets(
697
+ dataView: DataView,
698
+ baseBytePos: number,
699
+ offsets: number[],
700
+ value: number
701
+ ): boolean {
702
+ if (offsets.length < 4) {
703
+ return false;
704
+ }
705
+
706
+ const maxOffset = Math.max(...offsets);
707
+ if (baseBytePos + maxOffset >= dataView.byteLength) {
708
+ return false;
709
+ }
710
+
711
+ const scratchBuffer = new ArrayBuffer(4);
712
+ new DataView(scratchBuffer).setFloat32(0, value, false);
713
+ const bytes = new Uint8Array(scratchBuffer);
714
+ for (let idx = 0; idx < 4; idx++) {
715
+ dataView.setUint8(baseBytePos + offsets[idx], bytes[idx]);
716
+ }
717
+ return true;
718
+ }
719
+
668
720
  private _getGridDimensions(attr: any, repeatCount: number): { rows: number; cols: number } {
669
721
  if (attr && typeof attr.resolution === "string") {
670
722
  const match = attr.resolution.match(/(\d+)\s*x\s*(\d+)/i);
@@ -1017,6 +1069,45 @@ export default class RaftChannelSimulated implements RaftChannel {
1017
1069
  "us": 100000
1018
1070
  }
1019
1071
  },
1072
+ "SCD30": {
1073
+ "name": "CO2 Sensor",
1074
+ "desc": "",
1075
+ "manu": "Sensirion",
1076
+ "type": "SCD30",
1077
+ "clas": ["CO2", "TEMP", "RH"],
1078
+ "resp": {
1079
+ "b": 24,
1080
+ "a": [
1081
+ {
1082
+ "n": "CO2",
1083
+ "t": ">f",
1084
+ "at": [6, 7, 9, 10],
1085
+ "u": "ppm",
1086
+ "r": [0, 40000],
1087
+ "f": ".1f",
1088
+ "o": "float"
1089
+ },
1090
+ {
1091
+ "n": "temperature",
1092
+ "t": ">f",
1093
+ "at": [12, 13, 15, 16],
1094
+ "u": "C",
1095
+ "r": [-40, 125],
1096
+ "f": ".2f",
1097
+ "o": "float"
1098
+ },
1099
+ {
1100
+ "n": "humidity",
1101
+ "t": ">f",
1102
+ "at": [18, 19, 21, 22],
1103
+ "u": "%RH",
1104
+ "r": [0, 100],
1105
+ "f": ".2f",
1106
+ "o": "float"
1107
+ }
1108
+ ]
1109
+ }
1110
+ },
1020
1111
  "RoboticalServo": {
1021
1112
  "name": "Robotical Servo",
1022
1113
  "desc": "Servo",
@@ -136,6 +136,7 @@ export interface DeviceTypeInfo {
136
136
  desc: string;
137
137
  manu: string;
138
138
  type: string;
139
+ role?: string;
139
140
  resp?: DeviceTypePollRespMetadata;
140
141
  clas?: Array<string>;
141
142
  actions?: DeviceTypeAction[];
@@ -148,5 +149,8 @@ export interface DeviceTypeInfoRecs {
148
149
  export type RaftDevTypeInfoResponse = {
149
150
  req: string;
150
151
  rslt: string;
151
- devinfo: DeviceTypeInfo;
152
+ devinfo?: DeviceTypeInfo;
153
+ dtIdx?: number;
154
+ name?: string;
155
+ role?: string;
152
156
  };
@@ -2,7 +2,7 @@ import { DeviceManager } from "./RaftDeviceManager";
2
2
  import { DeviceTypeInfo } from "./RaftDeviceInfo";
3
3
  import RaftSystemUtils from "./RaftSystemUtils";
4
4
 
5
- function makeTypeInfo(name: string, respBytes: number, attrs: Array<{ n: string; t: string }>): DeviceTypeInfo {
5
+ function makeTypeInfo(name: string, respBytes: number, attrs: Array<{ n: string; t: string; at?: number | number[] }>): DeviceTypeInfo {
6
6
  return {
7
7
  name,
8
8
  desc: name,
@@ -123,6 +123,40 @@ describe("DeviceManager binary devbin parsing", () => {
123
123
  expect(deviceState.deviceAttributes.status.values).toEqual([4]);
124
124
  });
125
125
 
126
+ it("decodes current length-prefixed records with sparse absolute attribute offsets", async () => {
127
+ const scd30Info = makeTypeInfo("SCD30", 24, [
128
+ { n: "CO2", t: ">f", at: [6, 7, 9, 10] },
129
+ { n: "temperature", t: ">f", at: [12, 13, 15, 16] },
130
+ { n: "humidity", t: ">f", at: [18, 19, 21, 22] }
131
+ ]);
132
+ const deviceManager = await makeDeviceManager({ "42": scd30Info });
133
+ const rxMsg = Uint8Array.from([
134
+ 0x00, 0x80,
135
+ 0xDB, 0xFF, 0x00,
136
+ 0x00, 0x23,
137
+ 0x81,
138
+ 0x00, 0x00, 0x02, 0x61,
139
+ 0x00, 0x2a,
140
+ 0x07,
141
+ 0x1a,
142
+ 0x00, 0x01,
143
+ 0x00, 0x01, 0xb0,
144
+ 0x00, 0x01, 0xb0,
145
+ 0x43, 0xfa, 0x00, 0x00, 0x00, 0x00,
146
+ 0x41, 0xc8, 0x00, 0x00, 0x00, 0x00,
147
+ 0x42, 0x5e, 0x00, 0x00, 0x00, 0x00
148
+ ]);
149
+
150
+ await deviceManager.handleClientMsgBinary(rxMsg);
151
+
152
+ const deviceState = deviceManager.getDeviceState("1_261");
153
+ expect(deviceState.deviceType).toBe("SCD30");
154
+ expect(deviceState.deviceTimeline.totalSamplesAdded).toBe(1);
155
+ expect(deviceState.deviceAttributes.CO2.values).toEqual([500]);
156
+ expect(deviceState.deviceAttributes.temperature.values).toEqual([25]);
157
+ expect(deviceState.deviceAttributes.humidity.values).toEqual([55.5]);
158
+ });
159
+
126
160
  it("keeps Cog v1.9.5 direct device records distinct when bus and address are both zero", async () => {
127
161
  const lightInfo = makeTypeInfo("LightSensors", 16, [
128
162
  { n: "ch0", t: ">H" },