@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
@@ -0,0 +1,69 @@
1
+ # Web BLE Reconnect Recovery
2
+
3
+ Date: 2026-04-29
4
+
5
+ ## Issue
6
+
7
+ The Axiom offline data logging UI can be refreshed while Web Bluetooth is still
8
+ connected. In that path the browser may report `gatt.connect()` success and then
9
+ disconnect during `getPrimaryService()`, producing an error like:
10
+
11
+ ```text
12
+ GATT Server is disconnected. Cannot retrieve services. (Re)connect first with `device.gatt.connect`.
13
+ ```
14
+
15
+ Before this change, `RaftChannelBLE.web.ts` treated a failed primary-service
16
+ lookup as a terminal failure and returned `false` immediately. That meant a
17
+ transient reconnect race could fail the whole app-level connection attempt even
18
+ though the device was still running and still advertising.
19
+
20
+ The harsher path is a browser hard refresh while BLE is still connected. During
21
+ page unload, the normal app disconnect path cannot reliably complete because it
22
+ sends a graceful BLE command and waits asynchronously. If the page disappears
23
+ before that finishes, the browser can keep the GATT connection in a half-closed
24
+ state long enough for the next immediate reconnect to fail.
25
+
26
+ ## Solution
27
+
28
+ `RaftChannelBLE.connect()` now keeps the existing connection retry loop active
29
+ for primary-service lookup failures:
30
+
31
+ - If no supported primary service is found and more connection attempts remain,
32
+ it disconnects any still-open GATT connection.
33
+ - It waits briefly before retrying.
34
+ - It only returns `false` after the final service lookup attempt fails.
35
+
36
+ This preserves the previous final failure behavior while allowing transient
37
+ Web Bluetooth/GATT cleanup races to recover inside the raftjs channel.
38
+
39
+ `RaftConnector` also now exposes `disconnectForPageUnload()`. It is deliberately
40
+ smaller than the normal `disconnect()` path:
41
+
42
+ - It disables automatic lost-connection retry.
43
+ - It detaches the current channel from the connector immediately.
44
+ - It starts the channel-level GATT disconnect without waiting for the normal
45
+ graceful BLE command sequence.
46
+
47
+ The Axiom app uses this from `beforeunload`/`pagehide` so a browser hard refresh
48
+ still starts Web Bluetooth cleanup before the page is replaced. Normal user
49
+ disconnects continue to use the existing graceful path.
50
+
51
+ ## Validation
52
+
53
+ Validation was done from `Axiom-Experiment-App` against real Axiom hardware
54
+ (`Axiom009_adcf1e`) with firmware serial logs open on `/dev/cu.usbmodem2101`.
55
+
56
+ The diagnostic flow was:
57
+
58
+ 1. Connect over Web Bluetooth.
59
+ 2. Start Axiom offline data logging for LSM6DS at 1 Hz.
60
+ 3. Hard-refresh the browser without issuing the app-level Disconnect command.
61
+ 4. Immediately reconnect over Web Bluetooth.
62
+ 5. Confirm the offline logger is still active and points to the same log file.
63
+ 6. Stop the session and delete the generated e2e log.
64
+
65
+ The firmware logs showed no reboot or panic during the refresh/reconnect flow.
66
+ The logger stayed active across the browser refresh. The page-unload run showed
67
+ the Axiom app calling the immediate GATT disconnect path, the firmware observing
68
+ `BLE connection change isConn NO`, and reconnect reporting the same active log
69
+ with additional samples.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robotical/raftjs",
3
- "version": "2.1.0",
3
+ "version": "2.1.3",
4
4
  "description": "Javascript/TS library for Raft library",
5
5
  "main": "dist/web/main.js",
6
6
  "types": "dist/web/main.d.ts",
@@ -8,10 +8,10 @@
8
8
  "author": "Rob Dobson <rob@dobson.com>",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/robdobsn/raftjs.git"
11
+ "url": "https://github.com/robotical/raftjs.git"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/robdobsn/raftjs/issues"
14
+ "url": "https://github.com/robotical/raftjs/issues"
15
15
  },
16
16
  "license": "MIT",
17
17
  "keywords": [
@@ -31,21 +31,24 @@
31
31
  "watch-all": "tsc -p tsconfig.json --watch & tsc -p tsconfig.react-native.json --watch"
32
32
  },
33
33
  "devDependencies": {
34
+ "@types/jest": "^30.0.0",
34
35
  "@types/node": "^22.13.11",
36
+ "@types/text-encoding": "^0.0.40",
35
37
  "@types/web-bluetooth": "^0.0.21",
36
38
  "@typescript-eslint/eslint-plugin": "^8.27.0",
37
39
  "eslint": "^9.23.0",
40
+ "jest": "^30.3.0",
38
41
  "react-native-ble-plx": "^3.5.0",
39
- "typescript": "^5.8.2",
40
42
  "rimraf": "^6.0.1",
41
- "@types/text-encoding": "^0.0.40"
43
+ "ts-jest": "^29.4.6",
44
+ "typescript": "^5.8.2"
42
45
  },
43
46
  "dependencies": {
44
47
  "isomorphic-ws": "^5.0.0",
45
48
  "tslib": "^2.8.1"
46
49
  },
47
50
  "peerDependencies": {
48
- "react-native-ble-plx": "*",
49
- "react-native": "*"
51
+ "react-native": "*",
52
+ "react-native-ble-plx": "*"
50
53
  }
51
54
  }
@@ -0,0 +1,372 @@
1
+ import { tokenize, transpilePseudocodeToJs } from "./PseudocodeTranspiler";
2
+ import CustomAttrHandler from "./RaftCustomAttrHandler";
3
+ import { DeviceTypePollRespMetadata } from "./RaftDeviceInfo";
4
+
5
+ // ===== Pseudocode strings from DeviceTypeRecords.json =====
6
+
7
+ const PSEUDOCODE = {
8
+ max30101_fifo:
9
+ 'int N=(buf[0]+32-buf[2])%32;int k=3;int i=0;while(i<N){out.Red=(buf[k]<<16)|(buf[k+1]<<8)|buf[k+2];out.IR=(buf[k+3]<<16)|(buf[k+4]<<8)|buf[k+5];k+=6;i++;next;}',
10
+ lsm6ds_fifo:
11
+ 'int W=((buf[1]&0x0F)<<8)|buf[0];int P=((buf[3]&0x03)<<8)|buf[2];int skip=(6-P%6)%6;int N=(W-skip)/6;int maxN=(192-skip*2)/12;if(N>maxN){N=maxN;}if(N>16){N=16;}if(N<1){N=0;}int k=4+skip*2;int i=0;while(i<N){out.gx=(buf[k+1]<<8)|buf[k];out.gy=(buf[k+3]<<8)|buf[k+2];out.gz=(buf[k+5]<<8)|buf[k+4];out.ax=(buf[k+7]<<8)|buf[k+6];out.ay=(buf[k+9]<<8)|buf[k+8];out.az=(buf[k+11]<<8)|buf[k+10];k+=12;i++;next;}',
12
+ scd40_calc:
13
+ 'out.CO2 = buf[0]; out.Temp = -45.0 + (175.0 * buf[1] / 65535.0); out.Humidity = (100.0 * buf[2] / 65535.0);',
14
+ gravity_o2_calc:
15
+ 'float key = 20.9/120.0; float val = key * (buf[0] + (buf[1]/10.0) + (buf[2]/100.0)); out.oxygen = val;',
16
+ };
17
+
18
+ // Helper to build a minimal DeviceTypePollRespMetadata
19
+ function makeMeta(numBytes: number, attrNames: string[], customName: string, pseudocode: string): DeviceTypePollRespMetadata {
20
+ return {
21
+ b: numBytes,
22
+ a: attrNames.map(n => ({ n, t: 'B' })),
23
+ c: { n: customName, c: pseudocode },
24
+ } as DeviceTypePollRespMetadata;
25
+ }
26
+
27
+ // ===== Tokenizer tests =====
28
+
29
+ describe("tokenize", () => {
30
+ test("handles float literals before integers", () => {
31
+ const tokens = tokenize("20.9/120.0");
32
+ expect(tokens).toEqual([
33
+ { type: "NUM_FLOAT", value: "20.9" },
34
+ { type: "DIV_OP", value: "/" },
35
+ { type: "NUM_FLOAT", value: "120.0" },
36
+ ]);
37
+ });
38
+
39
+ test("handles hex literals", () => {
40
+ const tokens = tokenize("buf[1]&0x0F");
41
+ expect(tokens).toEqual([
42
+ { type: "ID", value: "buf" },
43
+ { type: "LBRACK", value: "[" },
44
+ { type: "NUM_INT", value: "1" },
45
+ { type: "RBRACK", value: "]" },
46
+ { type: "BITWISE_AND", value: "&" },
47
+ { type: "HEX_NUM", value: "0x0F" },
48
+ ]);
49
+ });
50
+
51
+ test("handles int keyword without matching identifiers containing 'int'", () => {
52
+ const tokens = tokenize("int interval=5;");
53
+ expect(tokens[0]).toEqual({ type: "INT_KW", value: "int" });
54
+ // 'interval' should be a single ID, not "int" + "erval"
55
+ expect(tokens[1]).toEqual({ type: "ID", value: "interval" });
56
+ });
57
+
58
+ test("handles multi-char operators before single-char", () => {
59
+ const tokens = tokenize("a<<b&&c||d==e!=f<=g>=h");
60
+ const types = tokens.map(t => t.type);
61
+ expect(types).toContain("LSHIFT");
62
+ expect(types).toContain("LOGICAL_AND");
63
+ expect(types).toContain("LOGICAL_OR");
64
+ expect(types).toContain("REL_OP"); // ==, !=, <=, >=
65
+ });
66
+
67
+ test("handles increment and decrement operators", () => {
68
+ const tokens = tokenize("i++;k--;");
69
+ expect(tokens).toEqual([
70
+ { type: "ID", value: "i" },
71
+ { type: "INC_OP", value: "++" },
72
+ { type: "SEMI", value: ";" },
73
+ { type: "ID", value: "k" },
74
+ { type: "DEC_OP", value: "--" },
75
+ { type: "SEMI", value: ";" },
76
+ ]);
77
+ });
78
+
79
+ test("handles dotted identifiers (out.X)", () => {
80
+ const tokens = tokenize("out.Red=val;");
81
+ expect(tokens[0]).toEqual({ type: "ID", value: "out.Red" });
82
+ });
83
+
84
+ test("'if' falls through to ID token", () => {
85
+ const tokens = tokenize("if(N>16){N=16;}");
86
+ expect(tokens[0]).toEqual({ type: "ID", value: "if" });
87
+ });
88
+
89
+ test("'while' is a keyword token", () => {
90
+ const tokens = tokenize("while(i<N){");
91
+ expect(tokens[0]).toEqual({ type: "WHILE", value: "while" });
92
+ });
93
+
94
+ test("'next' is a keyword token", () => {
95
+ const tokens = tokenize("next;");
96
+ expect(tokens[0]).toEqual({ type: "NEXT", value: "next" });
97
+ });
98
+
99
+ test("negative float literal tokenizes as SUB_OP + NUM_FLOAT", () => {
100
+ const tokens = tokenize("-45.0");
101
+ expect(tokens).toEqual([
102
+ { type: "SUB_OP", value: "-" },
103
+ { type: "NUM_FLOAT", value: "45.0" },
104
+ ]);
105
+ });
106
+
107
+ test("tokenizes all four pseudocode strings without errors", () => {
108
+ for (const [, code] of Object.entries(PSEUDOCODE)) {
109
+ const tokens = tokenize(code);
110
+ expect(tokens.length).toBeGreaterThan(0);
111
+ // Verify all characters are consumed by checking no unknown tokens
112
+ for (const t of tokens) {
113
+ expect(t.type).not.toBe("UNKNOWN");
114
+ }
115
+ }
116
+ });
117
+ });
118
+
119
+ // ===== Transpiler output tests =====
120
+
121
+ describe("transpilePseudocodeToJs", () => {
122
+ test("replaces int with let and wraps in Math.trunc", () => {
123
+ const js = transpilePseudocodeToJs("int x=1;");
124
+ expect(js).toContain("let x=Math.trunc(1);");
125
+ expect(js).not.toContain("int ");
126
+ });
127
+
128
+ test("replaces float with let without Math.trunc", () => {
129
+ const js = transpilePseudocodeToJs("float y=2.0;");
130
+ expect(js).toContain("let y=2.0;");
131
+ expect(js).not.toContain("float ");
132
+ expect(js).not.toMatch(/Math\.trunc/);
133
+ });
134
+
135
+ test("Math.trunc wraps division in int declaration", () => {
136
+ const js = transpilePseudocodeToJs("int N=(W-skip)/6;");
137
+ expect(js).toContain("let N=Math.trunc((W-skip)/6);");
138
+ });
139
+
140
+ test("removes next keyword", () => {
141
+ const js = transpilePseudocodeToJs("i++;next;");
142
+ expect(js).toContain("i++;");
143
+ // 'next' should be removed entirely; only the semicolons after i++ and after next remain
144
+ expect(js).not.toMatch(/\bnext\b/);
145
+ });
146
+
147
+ test("includes Proxy preamble", () => {
148
+ const js = transpilePseudocodeToJs("out.x=1;");
149
+ expect(js).toContain("new Proxy");
150
+ expect(js).toContain("attrValues[p]");
151
+ });
152
+
153
+ test("includes toInt16 helper", () => {
154
+ const js = transpilePseudocodeToJs("");
155
+ expect(js).toContain("function toInt16");
156
+ });
157
+
158
+ test("preserves out.X assignments verbatim", () => {
159
+ const js = transpilePseudocodeToJs("out.Red=(buf[k]<<16);");
160
+ expect(js).toContain("out.Red=(buf[k]<<16);");
161
+ });
162
+
163
+ test("preserves hex literals", () => {
164
+ const js = transpilePseudocodeToJs("int W=((buf[1]&0x0F)<<8)|buf[0];");
165
+ expect(js).toContain("0x0F");
166
+ });
167
+
168
+ test("gravity_o2_calc transpiles correctly (float, no Math.trunc)", () => {
169
+ const js = transpilePseudocodeToJs(PSEUDOCODE.gravity_o2_calc);
170
+ expect(js).toContain("let key=20.9/120.0;");
171
+ expect(js).toContain("let val=key");
172
+ expect(js).toContain("out.oxygen=val;");
173
+ // float declarations should NOT be wrapped in Math.trunc
174
+ expect(js).not.toMatch(/Math\.trunc/);
175
+ });
176
+
177
+ test("lsm6ds_fifo transpiles with Math.trunc for int divisions", () => {
178
+ const js = transpilePseudocodeToJs(PSEUDOCODE.lsm6ds_fifo);
179
+ expect(js).toContain("Math.trunc((W-skip)/6)");
180
+ expect(js).toContain("Math.trunc((192-skip*2)/12)");
181
+ });
182
+ });
183
+
184
+ // ===== End-to-end execution tests via CustomAttrHandler =====
185
+
186
+ describe("CustomAttrHandler with transpiled pseudocode", () => {
187
+
188
+ test("max30101_fifo: decodes 2 samples", () => {
189
+ // buf[0] = write pointer = 5, buf[2] = read pointer = 3 → N = (5+32-3)%32 = 2
190
+ // Samples start at buf[3], 6 bytes each (3 for Red, 3 for IR)
191
+ const bufData = new Uint8Array([
192
+ 5, 0, 3, // write ptr, unused, read ptr
193
+ 0x01, 0x02, 0x03, // Red sample 0: (1<<16)|(2<<8)|3 = 66051
194
+ 0x04, 0x05, 0x06, // IR sample 0: (4<<16)|(5<<8)|6 = 263430
195
+ 0x10, 0x20, 0x30, // Red sample 1: (16<<16)|(32<<8)|48 = 1056816
196
+ 0x40, 0x50, 0x60, // IR sample 1: (64<<16)|(80<<8)|96 = 4214880
197
+ ]);
198
+
199
+ const meta = makeMeta(bufData.length, ["Red", "IR"], "max30101_fifo", PSEUDOCODE.max30101_fifo);
200
+ const handler = new CustomAttrHandler();
201
+ const result = handler.handleAttr(meta, bufData, 0);
202
+
203
+ expect(result.length).toBe(2); // 2 attributes
204
+ expect(result[0]).toEqual([66051, 1056816]); // Red
205
+ expect(result[1]).toEqual([263430, 4214880]); // IR
206
+ });
207
+
208
+ test("max30101_fifo: N=0 produces empty arrays", () => {
209
+ // write ptr = read ptr → N = 0
210
+ const bufData = new Uint8Array([3, 0, 3]);
211
+ const meta = makeMeta(bufData.length, ["Red", "IR"], "max30101_fifo", PSEUDOCODE.max30101_fifo);
212
+ const handler = new CustomAttrHandler();
213
+ const result = handler.handleAttr(meta, bufData, 0);
214
+
215
+ expect(result[0]).toEqual([]); // Red
216
+ expect(result[1]).toEqual([]); // IR
217
+ });
218
+
219
+ test("lsm6ds_fifo: decodes 1 aligned sample (unsigned values)", () => {
220
+ // W = 6 words, P = 0 (aligned) → skip = 0, N = 1
221
+ // 4 bytes header + 12 bytes data = 16 bytes
222
+ const header = [6, 0, 0, 0]; // W=6, P=0
223
+ // gx=0x0100(256), gy=0x0200(512), gz=0x0300(768), ax=0x0400(1024), ay=0x0500(1280), az=0x0600(1536)
224
+ const sample = [0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, 0x06];
225
+ const bufData = new Uint8Array([...header, ...sample]);
226
+
227
+ const meta = makeMeta(bufData.length, ["gx", "gy", "gz", "ax", "ay", "az"], "lsm6ds_fifo", PSEUDOCODE.lsm6ds_fifo);
228
+ const handler = new CustomAttrHandler();
229
+ const result = handler.handleAttr(meta, bufData, 0);
230
+
231
+ // Custom handler produces unsigned values; sign conversion happens in RaftAttributeHandler
232
+ expect(result[0]).toEqual([256]); // gx
233
+ expect(result[1]).toEqual([512]); // gy
234
+ expect(result[2]).toEqual([768]); // gz
235
+ expect(result[3]).toEqual([1024]); // ax
236
+ expect(result[4]).toEqual([1280]); // ay
237
+ expect(result[5]).toEqual([1536]); // az
238
+ });
239
+
240
+ test("lsm6ds_fifo: integer division truncates (non-multiple word count)", () => {
241
+ // W = 7 words (not a multiple of 6), P = 0 → skip = 0, N = Math.trunc(7/6) = 1
242
+ // Without Math.trunc, N=1.166... would cause 2 iterations reading garbage
243
+ const header = [7, 0, 0, 0]; // W=7, P=0
244
+ const sample = [0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, 0x06, 0x00];
245
+ // Only provide 4 header + 12 data bytes (enough for 1 sample, not 2)
246
+ const bufData = new Uint8Array([...header, ...sample]);
247
+
248
+ const meta = makeMeta(bufData.length, ["gx", "gy", "gz", "ax", "ay", "az"], "lsm6ds_fifo", PSEUDOCODE.lsm6ds_fifo);
249
+ const handler = new CustomAttrHandler();
250
+ const result = handler.handleAttr(meta, bufData, 0);
251
+
252
+ // Should decode exactly 1 sample (truncated division), not 2
253
+ expect(result[0]).toEqual([1]); // gx
254
+ expect(result[0].length).toBe(1);
255
+ });
256
+
257
+ test("lsm6ds_fifo: clamps N to 16", () => {
258
+ // W = 200 words, P = 0 → N = 200/6 = 33, clamped to 16
259
+ // Need 4 header + 16*12 = 196 data bytes
260
+ const bufArr = new Array(200).fill(0);
261
+ bufArr[0] = 200; // W low byte
262
+ bufArr[1] = 0; // W high nibble
263
+ bufArr[2] = 0; // P low byte
264
+ bufArr[3] = 0; // P high byte
265
+ // Fill with recognizable data: each sample's gx low byte = sample index
266
+ for (let i = 0; i < 16; i++) {
267
+ bufArr[4 + i * 12] = i; // gx low byte
268
+ }
269
+ const bufData = new Uint8Array(bufArr);
270
+
271
+ const meta = makeMeta(bufData.length, ["gx", "gy", "gz", "ax", "ay", "az"], "lsm6ds_fifo", PSEUDOCODE.lsm6ds_fifo);
272
+ const handler = new CustomAttrHandler();
273
+ const result = handler.handleAttr(meta, bufData, 0);
274
+
275
+ expect(result[0].length).toBe(16); // gx has exactly 16 samples
276
+ });
277
+
278
+ test("scd40_calc: decodes CO2, Temp, Humidity", () => {
279
+ // buf[0] = CO2 raw, buf[1] = Temp raw, buf[2] = Humidity raw
280
+ // Using buf[0]=400 won't fit in single byte; the pseudocode treats buf values as numbers
281
+ // In real usage these would be pre-decoded multi-byte values packed into the buf
282
+ const bufData = new Uint8Array([100, 128, 200]);
283
+
284
+ const meta = makeMeta(bufData.length, ["CO2", "Temp", "Humidity"], "scd40_calc", PSEUDOCODE.scd40_calc);
285
+ const handler = new CustomAttrHandler();
286
+ const result = handler.handleAttr(meta, bufData, 0);
287
+
288
+ expect(result[0]).toEqual([100]); // CO2
289
+ expect(result[1][0]).toBeCloseTo(-45.0 + (175.0 * 128 / 65535.0), 5); // Temp
290
+ expect(result[2][0]).toBeCloseTo(100.0 * 200 / 65535.0, 5); // Humidity
291
+ });
292
+
293
+ test("gravity_o2_calc: decodes oxygen", () => {
294
+ // buf[0]=10, buf[1]=5, buf[2]=25 → val = (20.9/120) * (10 + 0.5 + 0.25) = 0.174166... * 10.75
295
+ const bufData = new Uint8Array([10, 5, 25]);
296
+
297
+ const meta = makeMeta(bufData.length, ["oxygen"], "gravity_o2_calc", PSEUDOCODE.gravity_o2_calc);
298
+ const handler = new CustomAttrHandler();
299
+ const result = handler.handleAttr(meta, bufData, 0);
300
+
301
+ const expected = (20.9 / 120.0) * (10 + 5 / 10.0 + 25 / 100.0);
302
+ expect(result[0][0]).toBeCloseTo(expected, 10);
303
+ });
304
+
305
+ test("explicit j field takes priority over c field", () => {
306
+ const meta: DeviceTypePollRespMetadata = {
307
+ b: 1,
308
+ a: [{ n: "val", t: "B" }],
309
+ c: {
310
+ n: "test_fn",
311
+ c: "out.val = buf[0] * 2;", // pseudocode would multiply by 2
312
+ j: "attrValues['val'].push(buf[0] * 10);", // explicit JS multiplies by 10
313
+ },
314
+ } as DeviceTypePollRespMetadata;
315
+
316
+ const bufData = new Uint8Array([7]);
317
+ const handler = new CustomAttrHandler();
318
+ const result = handler.handleAttr(meta, bufData, 0);
319
+
320
+ expect(result[0]).toEqual([70]); // j field used → 7 * 10
321
+ });
322
+
323
+ test("no custom function returns empty attribute vectors", () => {
324
+ const meta: DeviceTypePollRespMetadata = {
325
+ b: 1,
326
+ a: [{ n: "val", t: "B" }],
327
+ } as DeviceTypePollRespMetadata;
328
+
329
+ const bufData = new Uint8Array([42]);
330
+ const handler = new CustomAttrHandler();
331
+ const result = handler.handleAttr(meta, bufData, 0);
332
+
333
+ expect(result.length).toBe(1);
334
+ expect(result[0]).toEqual([]); // No function, so no values pushed
335
+ });
336
+
337
+ test("caches compiled functions", () => {
338
+ const meta = makeMeta(1, ["val"], "cache_test", "out.val = buf[0];");
339
+ const handler = new CustomAttrHandler();
340
+
341
+ const buf1 = new Uint8Array([5]);
342
+ const buf2 = new Uint8Array([10]);
343
+
344
+ handler.handleAttr(meta, buf1, 0);
345
+ handler.handleAttr(meta, buf2, 0);
346
+
347
+ // Both calls should work correctly (second uses cached function)
348
+ const result = handler.handleAttr(meta, new Uint8Array([15]), 0);
349
+ expect(result[0]).toEqual([15]);
350
+ });
351
+
352
+ test("uses available bytes when buffer is shorter than metadata length", () => {
353
+ const meta = makeMeta(10, ["val"], "test_short", "out.val = buf[0];");
354
+ const handler = new CustomAttrHandler();
355
+ const bufData = new Uint8Array([1, 2, 3]); // only 3 bytes, metadata length is 10
356
+
357
+ const result = handler.handleAttr(meta, bufData, 0);
358
+ expect(result).toEqual([[1]]);
359
+ });
360
+
361
+ test("msgBufIdx offsets into the message buffer", () => {
362
+ // The handler slices from msgBufIdx, so buf[0] should be the byte at that offset
363
+ const meta = makeMeta(3, ["oxygen"], "gravity_o2_calc", PSEUDOCODE.gravity_o2_calc);
364
+ const handler = new CustomAttrHandler();
365
+
366
+ const fullBuf = new Uint8Array([0xFF, 0xFF, 10, 5, 25, 0xFF]); // data at offset 2
367
+ const result = handler.handleAttr(meta, fullBuf, 2);
368
+
369
+ const expected = (20.9 / 120.0) * (10 + 5 / 10.0 + 25 / 100.0);
370
+ expect(result[0][0]).toBeCloseTo(expected, 10);
371
+ });
372
+ });
@@ -0,0 +1,127 @@
1
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
2
+ //
3
+ // PseudocodeTranspiler
4
+ // Transpiles Raft device pseudocode (from devInfoJson resp.c.c) to JavaScript function bodies
5
+ //
6
+ // Rob Dobson (C) 2024
7
+ //
8
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
9
+
10
+ export interface Token {
11
+ type: string;
12
+ value: string;
13
+ }
14
+
15
+ const TOKEN_SPEC: [string, RegExp][] = [
16
+ ['FLOAT_KW', /float\b/],
17
+ ['INT_KW', /int\b/],
18
+ ['RETURN', /return\b/],
19
+ ['WHILE', /while\b/],
20
+ ['NEXT', /next\b/],
21
+ ['HEX_NUM', /0x[0-9A-Fa-f]+/],
22
+ ['NUM_FLOAT', /\d+\.\d*/],
23
+ ['NUM_INT', /\d+/],
24
+ ['ID', /[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*/],
25
+ ['LSHIFT', /<</],
26
+ ['RSHIFT', />>/],
27
+ ['LOGICAL_AND', /&&/],
28
+ ['LOGICAL_OR', /\|\|/],
29
+ ['REL_OP', /==|!=|<=|>=|<|>/],
30
+ ['INC_OP', /\+\+/],
31
+ ['DEC_OP', /--/],
32
+ ['ASSIGN', /=/],
33
+ ['ADD_OP', /\+/],
34
+ ['SUB_OP', /-/],
35
+ ['MUL_OP', /\*/],
36
+ ['DIV_OP', /\//],
37
+ ['MOD_OP', /%/],
38
+ ['BITWISE_AND', /&/],
39
+ ['BITWISE_OR', /\|/],
40
+ ['BITWISE_XOR', /\^/],
41
+ ['BITWISE_NOT', /~/],
42
+ ['LOGICAL_NOT', /!/],
43
+ ['SEMI', /;/],
44
+ ['COMMA', /,/],
45
+ ['LPAREN', /\(/],
46
+ ['RPAREN', /\)/],
47
+ ['LBRACE', /\{/],
48
+ ['RBRACE', /\}/],
49
+ ['LBRACK', /\[/],
50
+ ['RBRACK', /\]/],
51
+ ['WS', /[ \t\n]+/],
52
+ ];
53
+
54
+ // Build the combined regex once at module load
55
+ const COMBINED_RE = new RegExp(
56
+ TOKEN_SPEC.map(([name, re]) => `(?<${name}>${re.source})`).join('|'),
57
+ 'g'
58
+ );
59
+
60
+ export function tokenize(code: string): Token[] {
61
+ // Reset lastIndex since we reuse the global regex
62
+ COMBINED_RE.lastIndex = 0;
63
+ const tokens: Token[] = [];
64
+ let match: RegExpExecArray | null;
65
+ while ((match = COMBINED_RE.exec(code)) !== null) {
66
+ for (const [name] of TOKEN_SPEC) {
67
+ if (match.groups![name] !== undefined) {
68
+ if (name !== 'WS') {
69
+ tokens.push({ type: name, value: match[0] });
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ return tokens;
76
+ }
77
+
78
+ const PREAMBLE =
79
+ 'const out = new Proxy({}, { set(_, p, v) { if (attrValues[p]) attrValues[p].push(v); return true; } });\n' +
80
+ 'function toInt16(lo, hi) { const u = (hi << 8) | lo; return u & 0x8000 ? u - 0x10000 : u; }\n' +
81
+ 'function toInt32(b0, b1, b2, b3) { return (b3 << 24) | (b2 << 16) | (b1 << 8) | b0; }\n';
82
+
83
+ export function transpilePseudocodeToJs(pseudocode: string): string {
84
+ const tokens = tokenize(pseudocode);
85
+ let js = PREAMBLE;
86
+
87
+ // Track int declarations so we can wrap the init expression in Math.trunc()
88
+ // to emulate C integer division semantics
89
+ let afterIntKw = false;
90
+ let wrappedInTrunc = false;
91
+
92
+ for (const token of tokens) {
93
+ switch (token.type) {
94
+ case 'INT_KW':
95
+ js += 'let ';
96
+ afterIntKw = true;
97
+ break;
98
+ case 'FLOAT_KW':
99
+ js += 'let ';
100
+ afterIntKw = false;
101
+ break;
102
+ case 'ASSIGN':
103
+ js += '=';
104
+ if (afterIntKw) {
105
+ js += 'Math.trunc(';
106
+ wrappedInTrunc = true;
107
+ }
108
+ break;
109
+ case 'SEMI':
110
+ if (wrappedInTrunc) {
111
+ js += ')';
112
+ wrappedInTrunc = false;
113
+ }
114
+ js += ';';
115
+ afterIntKw = false;
116
+ break;
117
+ case 'NEXT':
118
+ // no-op in JS — samples are pushed to arrays via the out Proxy
119
+ break;
120
+ default:
121
+ js += token.value;
122
+ break;
123
+ }
124
+ }
125
+
126
+ return js;
127
+ }