@adcops/autocore-react 3.0.40 → 3.1.5

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 (277) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/additional-docs/AutoCoreTagContext.md +441 -0
  3. package/dist/assets/BlocklyLogo.d.ts +1 -0
  4. package/dist/assets/BlocklyLogo.d.ts.map +1 -0
  5. package/dist/assets/Distance.d.ts +1 -0
  6. package/dist/assets/Distance.d.ts.map +1 -0
  7. package/dist/assets/JogLong.d.ts +1 -0
  8. package/dist/assets/JogLong.d.ts.map +1 -0
  9. package/dist/assets/JogMedium.d.ts +1 -0
  10. package/dist/assets/JogMedium.d.ts.map +1 -0
  11. package/dist/assets/JogShort.d.ts +1 -0
  12. package/dist/assets/JogShort.d.ts.map +1 -0
  13. package/dist/assets/PythonLogo.d.ts +1 -0
  14. package/dist/assets/PythonLogo.d.ts.map +1 -0
  15. package/dist/assets/Rotation3D.d.ts +1 -0
  16. package/dist/assets/Rotation3D.d.ts.map +1 -0
  17. package/dist/assets/RotationCcw.d.ts +1 -0
  18. package/dist/assets/RotationCcw.d.ts.map +1 -0
  19. package/dist/assets/RotationCcwA.d.ts +1 -0
  20. package/dist/assets/RotationCcwA.d.ts.map +1 -0
  21. package/dist/assets/RotationCcwB.d.ts +1 -0
  22. package/dist/assets/RotationCcwB.d.ts.map +1 -0
  23. package/dist/assets/RotationCcwC.d.ts +1 -0
  24. package/dist/assets/RotationCcwC.d.ts.map +1 -0
  25. package/dist/assets/RotationCw.d.ts +1 -0
  26. package/dist/assets/RotationCw.d.ts.map +1 -0
  27. package/dist/assets/RotationCwA.d.ts +1 -0
  28. package/dist/assets/RotationCwA.d.ts.map +1 -0
  29. package/dist/assets/RotationCwB.d.ts +1 -0
  30. package/dist/assets/RotationCwB.d.ts.map +1 -0
  31. package/dist/assets/RotationCwC.d.ts +1 -0
  32. package/dist/assets/RotationCwC.d.ts.map +1 -0
  33. package/dist/assets/Run.d.ts +1 -0
  34. package/dist/assets/Run.d.ts.map +1 -0
  35. package/dist/assets/Speed.d.ts +1 -0
  36. package/dist/assets/Speed.d.ts.map +1 -0
  37. package/dist/assets/SpeedFast.d.ts +1 -0
  38. package/dist/assets/SpeedFast.d.ts.map +1 -0
  39. package/dist/assets/SpeedMedium.d.ts +1 -0
  40. package/dist/assets/SpeedMedium.d.ts.map +1 -0
  41. package/dist/assets/SpeedNone.d.ts +1 -0
  42. package/dist/assets/SpeedNone.d.ts.map +1 -0
  43. package/dist/assets/SpeedSlow.d.ts +1 -0
  44. package/dist/assets/SpeedSlow.d.ts.map +1 -0
  45. package/dist/assets/Walk.d.ts +1 -0
  46. package/dist/assets/Walk.d.ts.map +1 -0
  47. package/dist/assets/index.d.ts +1 -0
  48. package/dist/assets/index.d.ts.map +1 -0
  49. package/dist/components/AutoCoreDevPanel.d.ts +144 -0
  50. package/dist/components/AutoCoreDevPanel.d.ts.map +1 -0
  51. package/dist/components/AutoCoreDevPanel.js +1 -0
  52. package/dist/components/BlocklyEditor.d.ts +1 -0
  53. package/dist/components/BlocklyEditor.d.ts.map +1 -0
  54. package/dist/components/BlocklyEditor.js +1 -1
  55. package/dist/components/CodeEditor.d.ts +2 -1
  56. package/dist/components/CodeEditor.d.ts.map +1 -0
  57. package/dist/components/CodeEditor.js +1 -1
  58. package/dist/components/FileList.d.ts +1 -0
  59. package/dist/components/FileList.d.ts.map +1 -0
  60. package/dist/components/FileList.js +1 -1
  61. package/dist/components/FileSelect.d.ts +1 -0
  62. package/dist/components/FileSelect.d.ts.map +1 -0
  63. package/dist/components/FileSelect.js +1 -1
  64. package/dist/components/FitText.d.ts +1 -0
  65. package/dist/components/FitText.d.ts.map +1 -0
  66. package/dist/components/FitText.js +1 -1
  67. package/dist/components/Indicator.d.ts +2 -1
  68. package/dist/components/Indicator.d.ts.map +1 -0
  69. package/dist/components/Indicator.js +1 -1
  70. package/dist/components/IndicatorButton.d.ts +2 -1
  71. package/dist/components/IndicatorButton.d.ts.map +1 -0
  72. package/dist/components/IndicatorButton.js +1 -1
  73. package/dist/components/IndicatorRect.d.ts +2 -1
  74. package/dist/components/IndicatorRect.d.ts.map +1 -0
  75. package/dist/components/JogPanel.d.ts +1 -0
  76. package/dist/components/JogPanel.d.ts.map +1 -0
  77. package/dist/components/Lamp.d.ts +2 -1
  78. package/dist/components/Lamp.d.ts.map +1 -0
  79. package/dist/components/Lamp.js +1 -1
  80. package/dist/components/Osk.d.ts +1 -0
  81. package/dist/components/Osk.d.ts.map +1 -0
  82. package/dist/components/Osk.js +1 -1
  83. package/dist/components/OskDialog.d.ts +1 -0
  84. package/dist/components/OskDialog.d.ts.map +1 -0
  85. package/dist/components/ProgressBarWithValue.d.ts +1 -0
  86. package/dist/components/ProgressBarWithValue.d.ts.map +1 -0
  87. package/dist/components/ProgressBarWithValue.js +1 -1
  88. package/dist/components/TextInput.d.ts +62 -103
  89. package/dist/components/TextInput.d.ts.map +1 -0
  90. package/dist/components/TextInput.js +1 -1
  91. package/dist/components/ToggleGroup.d.ts +2 -1
  92. package/dist/components/ToggleGroup.d.ts.map +1 -0
  93. package/dist/components/ToggleGroup.js +1 -1
  94. package/dist/components/ValueDisplay.d.ts +3 -2
  95. package/dist/components/ValueDisplay.d.ts.map +1 -0
  96. package/dist/components/ValueDisplay.js +1 -1
  97. package/dist/components/ValueIndicator.d.ts +2 -1
  98. package/dist/components/ValueIndicator.d.ts.map +1 -0
  99. package/dist/components/ValueIndicator.js +1 -1
  100. package/dist/components/ValueInput.d.ts +2 -1
  101. package/dist/components/ValueInput.d.ts.map +1 -0
  102. package/dist/components/ValueInput.js +1 -1
  103. package/dist/core/ActionMode.d.ts +1 -0
  104. package/dist/core/ActionMode.d.ts.map +1 -0
  105. package/dist/core/AutoCoreTagContext.d.ts +243 -0
  106. package/dist/core/AutoCoreTagContext.d.ts.map +1 -0
  107. package/dist/core/AutoCoreTagContext.js +1 -0
  108. package/dist/core/AutoCoreTagTypes.d.ts +162 -0
  109. package/dist/core/AutoCoreTagTypes.d.ts.map +1 -0
  110. package/dist/core/AutoCoreTagTypes.js +1 -0
  111. package/dist/core/EventEmitterContext.d.ts +295 -2
  112. package/dist/core/EventEmitterContext.d.ts.map +1 -0
  113. package/dist/core/EventEmitterContext.js +1 -1
  114. package/dist/core/IndicatorButtonState.d.ts +1 -0
  115. package/dist/core/IndicatorButtonState.d.ts.map +1 -0
  116. package/dist/core/IndicatorColor.d.ts +1 -0
  117. package/dist/core/IndicatorColor.d.ts.map +1 -0
  118. package/dist/core/MaskPatterns.d.ts +1 -0
  119. package/dist/core/MaskPatterns.d.ts.map +1 -0
  120. package/dist/core/NumerableTypes.d.ts +1 -0
  121. package/dist/core/NumerableTypes.d.ts.map +1 -0
  122. package/dist/core/NumerableTypes.js +1 -1
  123. package/dist/core/PositionContext.d.ts +1 -1
  124. package/dist/core/PositionContext.d.ts.map +1 -0
  125. package/dist/core/UniqueId.d.ts +1 -0
  126. package/dist/core/UniqueId.d.ts.map +1 -0
  127. package/dist/core/ValueSimulator.d.ts +2 -1
  128. package/dist/core/ValueSimulator.d.ts.map +1 -0
  129. package/dist/core/ValueSimulator.js +1 -1
  130. package/dist/core/hoc.d.ts +1 -0
  131. package/dist/core/hoc.d.ts.map +1 -0
  132. package/dist/core/hoc.js +1 -1
  133. package/dist/hooks/adsHooks.d.ts +1 -0
  134. package/dist/hooks/adsHooks.d.ts.map +1 -0
  135. package/dist/hooks/adsHooks.js +1 -1
  136. package/dist/hooks/commandHooks.d.ts +1 -0
  137. package/dist/hooks/commandHooks.d.ts.map +1 -0
  138. package/dist/hooks/commandHooks.js +1 -1
  139. package/dist/hooks/index.d.ts +1 -0
  140. package/dist/hooks/index.d.ts.map +1 -0
  141. package/dist/hooks/useAutoCoreTag.d.ts +26 -0
  142. package/dist/hooks/useAutoCoreTag.d.ts.map +1 -0
  143. package/dist/hooks/useAutoCoreTag.js +1 -0
  144. package/dist/hooks/useScaledValue.d.ts +1 -0
  145. package/dist/hooks/useScaledValue.d.ts.map +1 -0
  146. package/dist/hooks/useScaledValue.js +1 -1
  147. package/dist/hub/CommandMessage.d.ts +1 -0
  148. package/dist/hub/CommandMessage.d.ts.map +1 -0
  149. package/dist/hub/HubBase.d.ts +3 -2
  150. package/dist/hub/HubBase.d.ts.map +1 -0
  151. package/dist/hub/HubSimulate.d.ts +2 -1
  152. package/dist/hub/HubSimulate.d.ts.map +1 -0
  153. package/dist/hub/HubSimulate.js +1 -1
  154. package/dist/hub/HubTauri.d.ts +2 -1
  155. package/dist/hub/HubTauri.d.ts.map +1 -0
  156. package/dist/hub/HubTauri.js +1 -1
  157. package/dist/hub/HubWebSocket.d.ts +2 -1
  158. package/dist/hub/HubWebSocket.d.ts.map +1 -0
  159. package/dist/hub/HubWebSocket.js +1 -1
  160. package/dist/hub/index.d.ts +2 -1
  161. package/dist/hub/index.d.ts.map +1 -0
  162. package/dist/hub/index.js +1 -1
  163. package/dist/themes/adc-dark/blue/theme.css +305 -0
  164. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  165. package/package.json +33 -28
  166. package/readme.md +193 -22
  167. package/src/components/AutoCoreDevPanel.tsx +411 -0
  168. package/src/components/CodeEditor.tsx +2 -2
  169. package/src/components/FileList.tsx +2 -2
  170. package/src/components/FileSelect.tsx +31 -11
  171. package/src/components/Indicator.tsx +2 -2
  172. package/src/components/IndicatorButton.tsx +2 -2
  173. package/src/components/IndicatorRect.tsx +2 -2
  174. package/src/components/Lamp.tsx +3 -3
  175. package/src/components/TextInput.tsx +159 -240
  176. package/src/components/ToggleGroup.tsx +3 -3
  177. package/src/components/ValueDisplay.tsx +4 -4
  178. package/src/components/ValueIndicator.tsx +2 -2
  179. package/src/components/ValueInput.tsx +2 -2
  180. package/src/core/AutoCoreTagContext.tsx +1137 -0
  181. package/src/core/AutoCoreTagTypes.ts +202 -0
  182. package/src/core/EventEmitterContext.tsx +357 -43
  183. package/src/core/ValueSimulator.ts +2 -2
  184. package/src/hooks/useAutoCoreTag.ts +103 -0
  185. package/src/hub/HubBase.ts +3 -3
  186. package/src/hub/HubSimulate.ts +2 -2
  187. package/src/hub/HubTauri.ts +3 -3
  188. package/src/hub/HubWebSocket.ts +29 -32
  189. package/src/hub/index.ts +2 -2
  190. package/src/themes/adc-dark/_extensions.scss +327 -0
  191. package/tsconfig.json +43 -28
  192. package/docs/classes/components_BlocklyEditor.BlocklyEditor.html +0 -124
  193. package/docs/classes/components_CodeEditor.CodeEditor.html +0 -128
  194. package/docs/classes/components_JogPanel.JogPanel.html +0 -138
  195. package/docs/classes/components_Lamp.Lamp.html +0 -105
  196. package/docs/classes/components_TextInput.TextInput.html +0 -115
  197. package/docs/classes/components_ValueIndicator.ValueIndicator.html +0 -119
  198. package/docs/classes/components_ValueInput.ValueInput.html +0 -113
  199. package/docs/classes/hub_HubWebSocket.HubWebSocket.html +0 -106
  200. package/docs/enums/components_JogPanel.JogDistanceAction.html +0 -5
  201. package/docs/enums/components_JogPanel.JogPanelAction.html +0 -18
  202. package/docs/enums/components_JogPanel.JogSpeedAction.html +0 -5
  203. package/docs/enums/core_ActionMode.ActionMode.html +0 -6
  204. package/docs/enums/core_IndicatorColor.IndicatorColor.html +0 -23
  205. package/docs/functions/assets_BlocklyLogo.default.html +0 -1
  206. package/docs/functions/assets_Distance.default.html +0 -1
  207. package/docs/functions/assets_JogLong.default.html +0 -1
  208. package/docs/functions/assets_JogMedium.default.html +0 -1
  209. package/docs/functions/assets_JogShort.default.html +0 -1
  210. package/docs/functions/assets_PythonLogo.default.html +0 -1
  211. package/docs/functions/assets_Rotation3D.default.html +0 -1
  212. package/docs/functions/assets_RotationCcw.default.html +0 -1
  213. package/docs/functions/assets_RotationCcwA.default.html +0 -1
  214. package/docs/functions/assets_RotationCcwB.default.html +0 -1
  215. package/docs/functions/assets_RotationCcwC.default.html +0 -1
  216. package/docs/functions/assets_RotationCw.default.html +0 -1
  217. package/docs/functions/assets_RotationCwA.default.html +0 -1
  218. package/docs/functions/assets_RotationCwB.default.html +0 -1
  219. package/docs/functions/assets_RotationCwC.default.html +0 -1
  220. package/docs/functions/assets_Run.default.html +0 -1
  221. package/docs/functions/assets_Speed.default.html +0 -1
  222. package/docs/functions/assets_SpeedFast.default.html +0 -1
  223. package/docs/functions/assets_SpeedMedium.default.html +0 -1
  224. package/docs/functions/assets_SpeedNone.default.html +0 -1
  225. package/docs/functions/assets_SpeedSlow.default.html +0 -1
  226. package/docs/functions/assets_Walk.default.html +0 -1
  227. package/docs/functions/components_BlocklyEditor.createCustomToolbox.html +0 -6
  228. package/docs/functions/components_FileList.FileList.html +0 -21
  229. package/docs/functions/components_FitText.FitText.html +0 -8
  230. package/docs/functions/components_ToggleGroup.ToggleGroup.html +0 -5
  231. package/docs/interfaces/components_JogPanel.JogPanelButtonDefinition.html +0 -5
  232. package/docs/interfaces/components_ToggleGroup.ToggleGroupProps.html +0 -618
  233. package/docs/interfaces/core_IndicatorButtonState.IndicatorButtonState.html +0 -10
  234. package/docs/interfaces/hub_CommandMessage.CommandMessage.html +0 -6
  235. package/docs/interfaces/hub_CommandMessage.CommandMessageResult.html +0 -4
  236. package/docs/modules/assets.html +0 -23
  237. package/docs/modules/assets_BlocklyLogo.html +0 -2
  238. package/docs/modules/assets_Distance.html +0 -2
  239. package/docs/modules/assets_JogLong.html +0 -2
  240. package/docs/modules/assets_JogMedium.html +0 -2
  241. package/docs/modules/assets_JogShort.html +0 -2
  242. package/docs/modules/assets_PythonLogo.html +0 -2
  243. package/docs/modules/assets_Rotation3D.html +0 -2
  244. package/docs/modules/assets_RotationCcw.html +0 -2
  245. package/docs/modules/assets_RotationCcwA.html +0 -2
  246. package/docs/modules/assets_RotationCcwB.html +0 -2
  247. package/docs/modules/assets_RotationCcwC.html +0 -2
  248. package/docs/modules/assets_RotationCw.html +0 -2
  249. package/docs/modules/assets_RotationCwA.html +0 -2
  250. package/docs/modules/assets_RotationCwB.html +0 -2
  251. package/docs/modules/assets_RotationCwC.html +0 -2
  252. package/docs/modules/assets_Run.html +0 -2
  253. package/docs/modules/assets_Speed.html +0 -2
  254. package/docs/modules/assets_SpeedFast.html +0 -2
  255. package/docs/modules/assets_SpeedMedium.html +0 -2
  256. package/docs/modules/assets_SpeedNone.html +0 -2
  257. package/docs/modules/assets_SpeedSlow.html +0 -2
  258. package/docs/modules/assets_Walk.html +0 -2
  259. package/docs/modules/components_BlocklyEditor.html +0 -5
  260. package/docs/modules/components_CodeEditor.html +0 -3
  261. package/docs/modules/components_FileList.html +0 -3
  262. package/docs/modules/components_FitText.html +0 -3
  263. package/docs/modules/components_JogPanel.html +0 -9
  264. package/docs/modules/components_Lamp.html +0 -4
  265. package/docs/modules/components_TextInput.html +0 -2
  266. package/docs/modules/components_ToggleGroup.html +0 -6
  267. package/docs/modules/components_ValueIndicator.html +0 -4
  268. package/docs/modules/components_ValueInput.html +0 -2
  269. package/docs/modules/core_ActionMode.html +0 -2
  270. package/docs/modules/core_IndicatorButtonState.html +0 -2
  271. package/docs/modules/core_IndicatorColor.html +0 -2
  272. package/docs/modules/hub_CommandMessage.html +0 -3
  273. package/docs/modules/hub_HubWebSocket.html +0 -2
  274. package/docs/types/components_IndicatorButton.IndicatorButtonOptionsType.html +0 -1
  275. package/docs/variables/components_BlocklyEditor.StandardToolbox.html +0 -1
  276. package/docs/variables/components_JogPanel.DefaultLinearJogButtons.html +0 -2
  277. package/docs/variables/components_JogPanel.DefaultRotationJogButtons.html +0 -2
@@ -0,0 +1,1137 @@
1
+ /*
2
+ * Copyright (C) 2025 Automated Design Corp.. All Rights Reserved.
3
+ * Created Date: 2025-09-05 07:35:46
4
+ * -----
5
+ * Last Modified: 2026-01-17 10:32:56
6
+ * -----
7
+ *
8
+ */
9
+
10
+ /**
11
+ * @module core/AutoCoreTagContext
12
+ *
13
+ * @document ../../additional-docs/AutCoreTagContext.md
14
+ *
15
+ * @summary
16
+ * A React Context + Provider that:
17
+ * - Buffers **raw** controller values for every tag (exactly as received)
18
+ * - Derives **display** values from raw using current `scales` and optional tag `codec`
19
+ * - Recomputes display values **from raw** whenever a scale changes (no lossy re-scaling)
20
+ * - Inverse-scales on `write()` so the server always receives controller units
21
+ * - Supports ADS one-shot refresh, GNV-style scale objects, and resilient eager reads
22
+ *
23
+ * @remarks
24
+ * ❖ **Two representations per tag**
25
+ * - `rawValues[tagName]`: last controller value as-is (pre-scale, pre-codec)
26
+ * - `values[tagName]`: app-visible value derived from raw (scales/codec applied)
27
+ *
28
+ * ❖ **Scaling**
29
+ * - Provide `scales` keyed by scale group (e.g., `"position"`, `"load"`).
30
+ * - Each scale may point at a server tag (`serverTag`) that publishes an object
31
+ * `{ name, scale, label }` (GNV-style). When received, the Provider recomputes
32
+ * *only* affected display values **from raw**.
33
+ * - Manual `updateScale(name, scale, label)` writes the object back to the server
34
+ * (if `serverTag` is configured) and recomputes from raw locally.
35
+ *
36
+ * ❖ **Lifecycle order (eagerRead = true)**
37
+ * 1. Pull server scales first (so first ADS burst displays in correct units)
38
+ * 2. Register ADS symbols and subscribe all domains
39
+ * 3. Eager-pull non-ADS (refresh if available, else read_value)
40
+ * 4. ADS `refresh` one-shot publish
41
+ *
42
+ * ❖ **Writes**
43
+ * - `write(tagName, displayValue)` → (codec?) → inverse-scale → `write_value`
44
+ * - `tap(tagName)` pulses booleans `true → (300ms) → false` with proper domain envelopes
45
+ *
46
+ * ❖ **Resilience**
47
+ * - Eager reads tolerate “missing key” errors on fresh systems and keep going
48
+ * - All re-scaling recomputes from **raw** to avoid cumulative precision/race issues
49
+ *
50
+ * @example Minimal wiring
51
+ * ```tsx
52
+ * import { AutoCoreTagProvider } from "@adcops/autocore-react/core/AutoCoreTagContext";
53
+ * import type { TagConfig, ScaleConfig } from "@adcops/autocore-react/core/AutoCoreTagTypes";
54
+ *
55
+ * const acTagSpec = [
56
+ * { tagName: "pressPosition", domain: "ADS", valueType: "number", symbolName: "AX.Press.Position", scale: "position" },
57
+ * { tagName: "pressLoad", domain: "ADS", valueType: "number", symbolName: "AX.Press.Load", scale: "load" },
58
+ * { tagName: "isMotorsOn", domain: "ADS", valueType: "boolean", symbolName: "GIO.isMotorsOn" },
59
+ * ] as const satisfies readonly TagConfig[];
60
+ *
61
+ * const scales: Record<string, ScaleConfig> = {
62
+ * position: {
63
+ * name: "position",
64
+ * scale: 1.0,
65
+ * label: "mm",
66
+ * serverTag: { domain: "GNV", symbolName: "position_units" }, // expects {name, scale, label}
67
+ * },
68
+ * load: {
69
+ * name: "load",
70
+ * scale: 1.0,
71
+ * label: "N",
72
+ * serverTag: { domain: "GNV", symbolName: "load_units" }, // expects {name, scale, label}
73
+ * },
74
+ * };
75
+ *
76
+ * export function AppRoot() {
77
+ * return (
78
+ * <AutoCoreTagProvider tags={acTagSpec} scales={scales} eagerRead>
79
+ * <App />
80
+ * </AutoCoreTagProvider>
81
+ * );
82
+ * }
83
+ * ```
84
+ *
85
+ * @example Consuming values with hooks
86
+ * ```tsx
87
+ * import { makeAutoCoreTagHooks } from "@adcops/autocore-react/hooks/useAutoCoreTag";
88
+ * import { AutoCoreTagContext } from "@adcops/autocore-react/core/AutoCoreTagContext";
89
+ *
90
+ * export const AutoCoreHooks = makeAutoCoreTagHooks(AutoCoreTagContext, acTagSpec);
91
+ *
92
+ * function Dashboard() {
93
+ * const { value: pos } = AutoCoreHooks.useAutoCoreTag("pressPosition"); // display units
94
+ * const { value: load } = AutoCoreHooks.useAutoCoreTag("pressLoad");
95
+ * const { scales, updateScale } = AutoCoreHooks.useScales();
96
+ *
97
+ * return (
98
+ * <>
99
+ * <div>Position: {pos} {scales.position.label}</div>
100
+ * <div>Load: {load} {scales.load.label}</div>
101
+ * <button onClick={() => updateScale("position", 1/25.4, "in")}>inches</button>
102
+ * </>
103
+ * );
104
+ * }
105
+ * ```
106
+ */
107
+
108
+
109
+ import React, {
110
+ useRef,
111
+ createContext,
112
+ useCallback,
113
+ useContext,
114
+ useEffect,
115
+ useMemo,
116
+ useState,
117
+ type ReactNode,
118
+ } from "react";
119
+ import { EventEmitterContext } from "./EventEmitterContext";
120
+ import type {
121
+ BaseContextValue,
122
+ TagConfig,
123
+ ScaleConfig,
124
+ } from "./AutoCoreTagTypes";
125
+
126
+ /**
127
+ * Runtime type for the values map - allows any tag name to map to any value type.
128
+ * This is the generic type used when the specific tag configuration is not known at compile time.
129
+ */
130
+ type VMapRuntime = Record<string, unknown>;
131
+
132
+ /**
133
+ * The React Context that holds all AutoCore tag state and operations.
134
+ *
135
+ * This context provides:
136
+ * - `values`: Display values (with scaling/codecs applied)
137
+ * - `rawValues`: Raw controller values as received from server
138
+ * - `isLoading`: Whether initial data loading is complete
139
+ * - `write`: Function to write a value to a tag
140
+ * - `tap`: Function to pulse a boolean tag (true → delay → false)
141
+ * - `scales`: Current scale configurations
142
+ * - `updateScale`: Function to change a scale factor/label
143
+ *
144
+ * @see AutoCoreTagProvider for the provider implementation
145
+ * @see makeAutoCoreTagHooks for creating typed hooks from this context
146
+ */
147
+ export const AutoCoreTagContext = createContext<BaseContextValue<VMapRuntime>>({
148
+ values: {},
149
+ rawValues: {},
150
+ isLoading: true,
151
+ write: async () => { },
152
+ tap: async () => { },
153
+ scales: {},
154
+ updateScale: async () => { },
155
+ });
156
+
157
+ /**
158
+ * Standard response envelope from the AutoCore server.
159
+ * All server responses follow this structure.
160
+ *
161
+ * @template T - The type of data contained in the response
162
+ * @property success - Whether the operation succeeded
163
+ * @property valid - Whether the response data is valid
164
+ * @property data - The actual response payload
165
+ */
166
+ type HubEnvelope<T> = { success?: boolean; valid?: boolean; data?: T };
167
+
168
+ /**
169
+ * Utility function to create a promise that resolves after a specified delay.
170
+ * Used for tap() pulse timing and retry delays.
171
+ *
172
+ * @param ms - Number of milliseconds to sleep
173
+ * @returns Promise that resolves after the delay
174
+ */
175
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
176
+
177
+
178
+ /**
179
+ * AutoCoreTagProvider
180
+ *
181
+ * @description
182
+ * React Provider that wires up AutoCore tags to live server updates, buffers **raw**
183
+ * controller values, exposes **display** values (scales/codec applied), and handles
184
+ * writes/taps with proper domain envelopes. Scaling is **always** applied to display
185
+ * values only; raw values remain untouched and are the single source of truth.
186
+ *
187
+ * @props
188
+ * - `tags: readonly TagConfig[]`
189
+ * The full tag spec. Each tag declares `tagName`, `domain`, `symbolName`,
190
+ * `valueType` ("boolean"|"number"|"string"|"json"), optional `scale` name, and
191
+ * optional `codec` for JSON.
192
+ *
193
+ * - `scales?: Record<string, ScaleConfig>`
194
+ * Map of scale groups (`position`, `load`, …). Each scale may include an optional
195
+ * `serverTag` `{ domain, symbolName }`. When present, the provider reads that key
196
+ * first and subscribes to updates. The server must publish a GNV-style object
197
+ * `{ name, scale, label }` (stringified or object). When a new scale arrives,
198
+ * the provider recomputes affected display values **from raw**.
199
+ *
200
+ * - `eagerRead?: boolean = true`
201
+ * If true, the provider performs a non-ADS eager fetch pass (refresh if available,
202
+ * otherwise read_value with backoff) and then performs a single ADS `refresh`.
203
+ * Missing keys are common on clean systems—these are logged and skipped.
204
+ *
205
+ * - `children: ReactNode`
206
+ * Your app subtree that consumes `AutoCoreTagContext`.
207
+ *
208
+ * @context value
209
+ * - `values`: Partial<Record<tagName, unknown>> — app-visible (scaled/decoded)
210
+ * - `rawValues`: Record<tagName, unknown> — last controller values as received
211
+ * - `isLoading`: boolean — true until initial wiring completes
212
+ * - `write(tagName, displayValue)`: Promise<void>
213
+ * Serializes/encodes JSON if needed and inverse-scales numbers back to raw units.
214
+ * Dispatches `write_value` with appropriate domain envelope:
215
+ * - ADS: `{ symbol_name, value }`
216
+ * - GNV: `{ group:"ux", key, value }`
217
+ * - Other: `{ key, value }`
218
+ * - `tap(tagName)`: Promise<void>
219
+ * For boolean tags only. Pulses true → 300ms → false with the proper envelope.
220
+ * - `scales`: Record<string, ScaleConfig> — current factors/labels
221
+ * - `updateScale(name, scale, label)`: Promise<void>
222
+ * Writes `{ name, scale, label }` to the configured `serverTag` (if present) and
223
+ * recomputes display values **from raw** locally.
224
+ *
225
+ * @example With server-driven scales (GNV) and ADS data
226
+ * ```tsx
227
+ * const tags: readonly TagConfig[] = [
228
+ * { tagName: "pressPosition", domain: "ADS", valueType: "number", symbolName: "AX.Press.Pos", scale: "position" },
229
+ * { tagName: "pressLoad", domain: "ADS", valueType: "number", symbolName: "AX.Press.Load", scale: "load" },
230
+ * { tagName: "isMotorsOn", domain: "ADS", valueType: "boolean", symbolName: "GIO.isMotorsOn" },
231
+ * // JSON with codec example
232
+ * { tagName: "jobMeta", domain: "GNV", valueType: "json", symbolName: "job_meta",
233
+ * codec: {
234
+ * fromServer: (raw) => (typeof raw === "string" ? JSON.parse(raw) : raw),
235
+ * toServer: (val) => JSON.stringify(val),
236
+ * }
237
+ * },
238
+ * ] as const;
239
+ *
240
+ * const scales: Record<string, ScaleConfig> = {
241
+ * position: {
242
+ * name: "position",
243
+ * scale: 1, label: "mm",
244
+ * serverTag: { domain: "GNV", symbolName: "position_units" } // expects {name, scale, label}
245
+ * },
246
+ * load: {
247
+ * name: "load",
248
+ * scale: 1, label: "N",
249
+ * serverTag: { domain: "GNV", symbolName: "load_units" } // expects {name, scale, label}
250
+ * }
251
+ * };
252
+ *
253
+ * <AutoCoreTagProvider tags={tags} scales={scales} eagerRead>
254
+ * <YourApp/>
255
+ * </AutoCoreTagProvider>
256
+ * ```
257
+ *
258
+ * @example Reading values, raw vs display, and writing
259
+ * ```tsx
260
+ * import { makeAutoCoreTagHooks } from "../hooks/useAutoCoreTag";
261
+ * import { AutoCoreTagContext } from "../core/AutoCoreTagContext";
262
+ *
263
+ * const Hooks = makeAutoCoreTagHooks(AutoCoreTagContext, tags);
264
+ *
265
+ * function Status() {
266
+ * const { value: pos, isLoading } = Hooks.useAutoCoreTag("pressPosition"); // scaled
267
+ * const { value: rawPos } = (() => {
268
+ * // access raw via context when needed for debugging
269
+ * const ctx = React.useContext(AutoCoreTagContext);
270
+ * return { value: ctx.rawValues["pressPosition"] };
271
+ * })();
272
+ *
273
+ * const { scales, updateScale } = Hooks.useScales();
274
+ *
275
+ * return (
276
+ * <>
277
+ * <div>Pos: {pos} {scales.position.label} (raw: {String(rawPos)})</div>
278
+ * <button onClick={() => updateScale("position", 1/25.4, "in")}>inches</button>
279
+ * </>
280
+ * );
281
+ * }
282
+ * ```
283
+ *
284
+ * @example Tapping booleans and writing numbers in display units
285
+ * ```tsx
286
+ * const { tap } = Hooks.useAutoCoreTag("isMotorsOn");
287
+ * const { write } = Hooks.useAutoCoreTag("pressPosition");
288
+ *
289
+ * <button onClick={() => tap()}>Motors TAP</button>
290
+ * <button onClick={() => write(10.0)}>Jog to 10 (display units)</button>
291
+ * ```
292
+ */
293
+ export const AutoCoreTagProvider: React.FC<{
294
+ children: ReactNode;
295
+ tags: readonly TagConfig[];
296
+ scales?: Record<string, ScaleConfig>;
297
+ eagerRead?: boolean;
298
+ }> = ({ children, tags, scales = {}, eagerRead = true }) => {
299
+ const startedRef = useRef(false);
300
+
301
+ const { invoke, isConnected, subscribe, unsubscribe } =
302
+ useContext(EventEmitterContext);
303
+
304
+ /** Raw (controller) values as received from the server (pre-scale, pre-codec). */
305
+ const [rawValues, setRawValues] = useState<Record<string, unknown>>(() => {
306
+ const seed: Record<string, unknown> = {};
307
+ for (const t of tags) {
308
+ if (t.initialValue !== undefined) {
309
+ // Interpret initialValue as **raw**.
310
+ seed[t.tagName] = t.initialValue;
311
+ }
312
+ }
313
+ return seed;
314
+ });
315
+
316
+ /** App-visible display values, computed from raw using scales/codecs. */
317
+ const [values, setValues] = useState<Record<string, unknown>>({});
318
+
319
+ /** Scales (current factor/label) */
320
+ const [scaleValues, setScaleValues] =
321
+ useState<Record<string, ScaleConfig>>(scales);
322
+
323
+ /** Keep refs for subscription callbacks without re-subscribing */
324
+ const scaleRef = useRef(scaleValues);
325
+ const rawRef = useRef(rawValues);
326
+ useEffect(() => { scaleRef.current = scaleValues; }, [scaleValues]);
327
+ useEffect(() => { rawRef.current = rawValues; }, [rawValues]);
328
+
329
+ const [isLoading, setIsLoading] = useState(true);
330
+
331
+ /**
332
+ * Converts a raw controller value to a display value.
333
+ *
334
+ * This function applies transformations in the following order:
335
+ * 1. **Numeric scaling**: If the tag has a scale group and the value is a number,
336
+ * multiply by the scale factor (e.g., mm → inches)
337
+ * 2. **JSON codec**: If the tag has a codec with `fromServer`, apply it to decode
338
+ * the server representation (e.g., JSON string → object)
339
+ * 3. **Pass-through**: If no transformations apply, return the raw value as-is
340
+ *
341
+ * @param tag - The tag configuration containing valueType, scale group, and optional codec
342
+ * @param raw - The raw value received from the controller/server
343
+ * @returns The display value suitable for showing in the UI
344
+ *
345
+ * @example
346
+ * // For a position tag with scale factor 0.03937 (mm to inches)
347
+ * toDisplay(positionTag, 254) // returns 10.0 (inches)
348
+ *
349
+ * @example
350
+ * // For a JSON tag with codec
351
+ * toDisplay(jsonTag, '{"name":"test"}') // returns { name: "test" }
352
+ */
353
+ const toDisplay = useCallback((tag: TagConfig, raw: unknown): unknown => {
354
+ const { valueType, scale, codec } = tag;
355
+
356
+ // 1) numeric scaling: multiply raw by scale factor
357
+ if (valueType === "number" && typeof raw === "number" && scale) {
358
+ const s = scaleRef.current[scale];
359
+ const factor = s?.scale ?? 1;
360
+ return raw * factor;
361
+ }
362
+
363
+ // 2) codec for json (optional): decode server representation
364
+ if (valueType === "json" && codec?.fromServer) {
365
+ try { return codec.fromServer(raw); } catch { /* fall through */ }
366
+ }
367
+
368
+ // 3) pass-through: no transformation needed
369
+ return raw;
370
+ }, []);
371
+
372
+ /**
373
+ * Converts a display value back to a raw controller value for writing.
374
+ *
375
+ * This is the inverse of `toDisplay`. It reverses transformations so that
376
+ * writes from the UI are sent to the server in controller units.
377
+ *
378
+ * Transformations are applied in reverse order:
379
+ * 1. **JSON codec**: If the tag has a codec with `toServer`, apply it to encode
380
+ * the value for the server (e.g., object → JSON string)
381
+ * 2. **Inverse scaling**: If the tag has a scale group and the value is a number,
382
+ * divide by the scale factor to convert back to controller units
383
+ *
384
+ * @param tag - The tag configuration containing valueType, scale group, and optional codec
385
+ * @param display - The display value from the UI (in user-facing units)
386
+ * @returns The raw value to send to the controller/server
387
+ *
388
+ * @example
389
+ * // For a position tag with scale factor 0.03937 (mm to inches)
390
+ * // User enters 10 inches, need to send 254 mm to controller
391
+ * toServer(positionTag, 10.0) // returns 254.0 (mm)
392
+ *
393
+ * @example
394
+ * // For a JSON tag with codec
395
+ * toServer(jsonTag, { name: "test" }) // returns '{"name":"test"}'
396
+ */
397
+ const toServer = useCallback((tag: TagConfig, display: unknown): unknown => {
398
+ const { valueType, scale, codec } = tag;
399
+
400
+ // 1) invert codec first (json): encode for server
401
+ if (valueType === "json" && codec?.toServer) {
402
+ try { display = codec.toServer(display as any); } catch { /* fall through */ }
403
+ }
404
+
405
+ // 2) inverse numeric scaling: divide by scale factor
406
+ if (valueType === "number" && typeof display === "number" && scale) {
407
+ const s = scaleRef.current[scale];
408
+ const factor = s?.scale ?? 1;
409
+ return display / factor;
410
+ }
411
+
412
+ return display;
413
+ }, []);
414
+
415
+ /**
416
+ * Recomputes display values for all tags that use a specific scale group.
417
+ *
418
+ * This function is called when a scale factor changes. It:
419
+ * 1. Finds all tags that belong to the specified scale group
420
+ * 2. For each tag, retrieves the current RAW value (not the old display value)
421
+ * 3. Applies the NEW scale factor to compute updated display values
422
+ *
423
+ * **Important**: This always recomputes from RAW values to avoid cumulative
424
+ * precision errors. If we scaled from display values, repeated scale changes
425
+ * would accumulate floating-point errors.
426
+ *
427
+ * @param scaleName - The name of the scale group to recompute (e.g., "position", "load")
428
+ *
429
+ * @example
430
+ * // When user switches from mm to inches:
431
+ * // 1. Scale factor changes from 1.0 to 0.03937
432
+ * // 2. rescaleFromRaw("position") is called
433
+ * // 3. All position tags are recomputed: raw_mm * 0.03937 = display_inches
434
+ */
435
+ const rescaleFromRaw = useCallback((scaleName: string) => {
436
+ const affected = tags.filter(t => t.scale === scaleName);
437
+ if (!affected.length) return;
438
+
439
+ setValues(prev => {
440
+ const next = { ...prev };
441
+ for (const tag of affected) {
442
+ const raw = rawRef.current[tag.tagName];
443
+ if (typeof raw !== "number") continue;
444
+ const disp = toDisplay(tag, raw);
445
+ if (next[tag.tagName] !== disp) next[tag.tagName] = disp;
446
+ }
447
+ return next;
448
+ });
449
+ }, [tags, toDisplay]);
450
+
451
+ /**
452
+ * Handles incoming value updates from the server.
453
+ *
454
+ * This is the callback invoked when the server broadcasts a new value for a tag.
455
+ * It performs two operations:
456
+ *
457
+ * 1. **Store raw value**: Saves the unmodified controller value in `rawValues`.
458
+ * This is the source of truth that scaling is computed from.
459
+ *
460
+ * 2. **Compute display value**: Applies scaling/codecs via `toDisplay()` and
461
+ * stores the result in `values` for UI consumption.
462
+ *
463
+ * Both updates are done immutably - if the value hasn't changed, React state
464
+ * is not updated (preventing unnecessary re-renders).
465
+ *
466
+ * @param tag - The tag configuration for the updated value
467
+ * @param raw - The raw value received from the server (in controller units)
468
+ *
469
+ * @example
470
+ * // Server broadcasts: MODBUS/simulator.holding_0 with value 42
471
+ * // EventEmitter callback receives data: { value: 42 }
472
+ * handleTagUpdate(holdingTag, 42);
473
+ * // rawValues["holdingReg0"] = 42
474
+ * // values["holdingReg0"] = 42 (or scaled if scale configured)
475
+ */
476
+ const handleTagUpdate = useCallback((tag: TagConfig, raw: unknown) => {
477
+ // Store the raw controller value (source of truth for scaling)
478
+ setRawValues(prev =>
479
+ prev[tag.tagName] === raw ? prev : { ...prev, [tag.tagName]: raw }
480
+ );
481
+ // Compute and store the display value (with scaling/codecs applied)
482
+ const display = toDisplay(tag, raw);
483
+ setValues(prev =>
484
+ prev[tag.tagName] === display ? prev : { ...prev, [tag.tagName]: display }
485
+ );
486
+ }, [toDisplay]);
487
+
488
+ /**
489
+ * Performs eager (one-time) reads for non-ADS domain tags.
490
+ *
491
+ * This function fetches initial values for tags that don't use the ADS protocol.
492
+ * It uses a worker pool pattern to read multiple tags concurrently while
493
+ * respecting rate limits.
494
+ *
495
+ * **Strategy for each tag:**
496
+ * 1. First, try `refresh` command - this asks the server to broadcast the current
497
+ * value (if the domain supports it). This is preferred because it uses the
498
+ * normal broadcast path.
499
+ * 2. If `refresh` fails, fall back to `read_value` - a direct request/response
500
+ * read of the tag value.
501
+ *
502
+ * **Rate limiting:**
503
+ * - Uses configurable concurrency (default 4 parallel workers)
504
+ * - Adds delay + random jitter between requests to avoid overwhelming the server
505
+ *
506
+ * **Error handling:**
507
+ * - Missing keys are common on fresh systems and are silently ignored
508
+ * - Other errors are caught but don't stop the worker pool
509
+ *
510
+ * @param domain - The domain to read from (e.g., "GNV", "MODBUS", "DATASTORE")
511
+ * @param tagList - List of tags to read values for
512
+ * @param opts - Optional configuration for rate limiting
513
+ * @param opts.concurrency - Number of parallel workers (default: 4)
514
+ * @param opts.minDelayMs - Minimum delay between requests per worker (default: 20ms)
515
+ * @param opts.jitterMs - Random additional delay range (default: 0-40ms)
516
+ *
517
+ * @example
518
+ * // Read all GNV tags with default rate limiting
519
+ * await eagerPullNonADS("GNV", gnvTags);
520
+ *
521
+ * @example
522
+ * // Read MODBUS tags with custom concurrency
523
+ * await eagerPullNonADS("MODBUS", modbusTags, { concurrency: 2, minDelayMs: 50 });
524
+ */
525
+ const eagerPullNonADS = useCallback(
526
+ async (
527
+ domain: string,
528
+ tagList: readonly TagConfig[],
529
+ opts?: { concurrency?: number; minDelayMs?: number; jitterMs?: number }
530
+ ) => {
531
+ const concurrency = opts?.concurrency ?? 4;
532
+ const minDelayMs = opts?.minDelayMs ?? 20;
533
+ const jitterMs = opts?.jitterMs ?? 40;
534
+
535
+ // Shared index counter for work-stealing pattern
536
+ let index = 0;
537
+
538
+ // Create worker pool - each worker pulls tags from shared queue
539
+ const workers = Array.from({ length: concurrency }, () => (async () => {
540
+ while (true) {
541
+ // Atomically grab next tag index
542
+ const i = index++;
543
+ if (i >= tagList.length) return; // No more work
544
+ const tag = tagList[i];
545
+
546
+ try {
547
+ // Strategy 1: Try refresh (asks server to broadcast current value)
548
+ let usedPublishPath = false;
549
+ try {
550
+ await invoke(domain, "refresh", { topic: tag.symbolName });
551
+ usedPublishPath = true;
552
+ } catch { /* refresh not supported, fall through to read_value */ }
553
+
554
+ // Strategy 2: Fall back to direct read_value
555
+ if (!usedPublishPath) {
556
+ // Build domain-specific payload (GNV uses group:"ux")
557
+ const payload =
558
+ domain.toUpperCase() === "GNV"
559
+ ? { group: "ux", key: tag.symbolName }
560
+ : { key: tag.symbolName };
561
+
562
+ if (tag.options) (payload as any).options = tag.options;
563
+
564
+ try {
565
+ const resp: any = await invoke(domain, "read_value", payload);
566
+ // Server always returns { success, valid, data }
567
+ if (resp?.success && resp?.valid) {
568
+ handleTagUpdate(tag, resp.data);
569
+ }
570
+ } catch (err) {
571
+ // Missing key on fresh systems is normal; keep going.
572
+ }
573
+ }
574
+ } catch (outerErr) {
575
+ // Never throw out of the worker loop - one failure shouldn't stop others
576
+ } finally {
577
+ // Rate limiting: delay + jitter before next request
578
+ const extra = Math.floor(Math.random() * jitterMs);
579
+ await sleep(minDelayMs + extra);
580
+ }
581
+ }
582
+ })());
583
+
584
+ // Wait for all workers to complete
585
+ await Promise.all(workers);
586
+ },
587
+ [invoke, handleTagUpdate]
588
+ );
589
+
590
+ /**
591
+ * Fetches server-stored scale configurations before loading tag values.
592
+ *
593
+ * Scales may be stored on the server (typically in the GNV domain) so that
594
+ * unit preferences persist across sessions. This function reads those stored
595
+ * scale values before any tag values are loaded.
596
+ *
597
+ * **Why this runs first:**
598
+ * If we loaded tag values before knowing the correct scale factor, the initial
599
+ * display would show wrong units, then "jump" when the scale arrived. By loading
600
+ * scales first, the first display of tag values is already in correct units.
601
+ *
602
+ * **Server format:**
603
+ * The server stores scales as JSON objects: `{ name, scale, label }`
604
+ * For example: `{ name: "position", scale: 0.03937, label: "in" }`
605
+ *
606
+ * @example Scale configuration with serverTag
607
+ * ```typescript
608
+ * const scales = {
609
+ * position: {
610
+ * name: "position",
611
+ * scale: 1.0, // default until server value loaded
612
+ * label: "mm", // default until server value loaded
613
+ * serverTag: { domain: "GNV", symbolName: "position_units" }
614
+ * }
615
+ * };
616
+ * ```
617
+ */
618
+ const pullServerScales = useCallback(async () => {
619
+ for (const [scaleName, cfg] of Object.entries(scales)) {
620
+ // Skip scales that don't have server storage configured
621
+ if (!cfg.serverTag) continue;
622
+ const { domain, symbolName } = cfg.serverTag;
623
+
624
+ try {
625
+ // Read the scale configuration from server (GNV-style payload)
626
+ const respUnknown = await invoke(domain, "read_value", {
627
+ group: "ux",
628
+ key: symbolName,
629
+ });
630
+
631
+ // Parse response envelope
632
+ const env = respUnknown as HubEnvelope<string>;
633
+ if (env.data && (env.success ?? true) && (env.valid ?? true)) {
634
+ // Server stores as JSON string, parse it
635
+ const env_data = JSON.parse(env.data) as ScaleConfig;
636
+ if (env_data && typeof env_data.scale === "number" ) {
637
+ const { scale, label } = env_data;
638
+ // Update local scale state
639
+ setScaleValues(prev => ({
640
+ ...prev,
641
+ [scaleName]: {
642
+ ...prev[scaleName],
643
+ scale,
644
+ label: label ?? prev[scaleName]?.label ?? "---",
645
+ },
646
+ }));
647
+ // Recompute any existing display values with new scale
648
+ rescaleFromRaw(scaleName);
649
+ }
650
+ }
651
+ } catch {
652
+ // Scale not present on server yet - use defaults from props
653
+ }
654
+ }
655
+ }, [invoke, scales, rescaleFromRaw]);
656
+
657
+ /**
658
+ * ==========================================================================
659
+ * MAIN SUBSCRIPTION WIRING EFFECT
660
+ * ==========================================================================
661
+ *
662
+ * This useEffect is the heart of the AutoCoreTagProvider. It sets up all
663
+ * the plumbing to receive live tag updates from the server.
664
+ *
665
+ * IMPORTANT CONCEPTS:
666
+ *
667
+ * 1. **Local EventEmitter subscriptions** (subscribe() function):
668
+ * - These register callbacks in the browser to receive broadcast messages
669
+ * - When the server sends a broadcast, the WebSocket layer receives it and
670
+ * the EventEmitter dispatches it to matching subscribers
671
+ * - This is CLIENT-SIDE ONLY - it does NOT tell the server anything
672
+ *
673
+ * 2. **Server-side subscriptions** (invoke with "subscribe" or "register_symbol"):
674
+ * - These tell the server "please start sending me updates for X"
675
+ * - Without this, the server doesn't know to broadcast anything
676
+ * - ADS: Uses "register_symbol" which implicitly subscribes
677
+ * - Other domains (MODBUS, etc.): Need explicit "subscribe" command
678
+ *
679
+ * DATA FLOW:
680
+ *
681
+ * [Server changes value]
682
+ * ↓
683
+ * [Server broadcasts if client is subscribed] ← requires server-side subscription
684
+ * ↓
685
+ * [WebSocket receives broadcast]
686
+ * ↓
687
+ * [EventEmitter dispatches to local subscribers] ← requires local subscription
688
+ * ↓
689
+ * [handleTagUpdate callback fires]
690
+ * ↓
691
+ * [rawValues and values state updated]
692
+ * ↓
693
+ * [React re-renders with new values]
694
+ */
695
+ useEffect(() => {
696
+ // Track if component is still mounted (for async safety)
697
+ let mounted = true;
698
+ // Collect subscription IDs for cleanup on unmount
699
+ const subscriptions: number[] = [];
700
+
701
+ /**
702
+ * Main registration and subscription function.
703
+ *
704
+ * This orchestrates the entire setup process in a specific order to ensure
705
+ * values display correctly from the first render.
706
+ */
707
+ const registerAndSubscribe = async () => {
708
+
709
+ // ──────────────────────────────────────────────────────────────────
710
+ // STEP 0: Group tags by domain
711
+ // ──────────────────────────────────────────────────────────────────
712
+ // We process tags domain-by-domain because each domain has different
713
+ // registration and subscription requirements.
714
+ const tagsByDomain = new Map<string, TagConfig[]>();
715
+ for (const tag of tags) {
716
+ const list = tagsByDomain.get(tag.domain);
717
+ if (list) list.push(tag); else tagsByDomain.set(tag.domain, [tag]);
718
+ }
719
+
720
+ try {
721
+ // ──────────────────────────────────────────────────────────────
722
+ // STEP 1: Load server-stored scale factors FIRST
723
+ // ──────────────────────────────────────────────────────────────
724
+ // Why first? So when tag values arrive, they're already displayed
725
+ // in the correct units. Otherwise values would "jump" when scales load.
726
+ await pullServerScales();
727
+
728
+ // ──────────────────────────────────────────────────────────────
729
+ // STEP 2: Process each domain
730
+ // ──────────────────────────────────────────────────────────────
731
+ for (const [domain, tagList] of tagsByDomain.entries()) {
732
+
733
+ // ──────────────────────────────────────────────────────────
734
+ // STEP 2a: Subscribe to onchange notification
735
+ // ──────────────────────────────────────────────────────────
736
+ if (domain.toUpperCase() === "ADS") {
737
+ // ADS (Beckhoff TwinCAT) requires explicit symbol registration.
738
+ // This tells the ADS server "I want to access this PLC variable."
739
+ // The registration also sets up server-side subscriptions for
740
+ // the ADS notification system.
741
+
742
+ for (const tag of tagList) {
743
+ try {
744
+ const invokeOptions = tag.options
745
+ ? { symbol_name: tag.symbolName, options: tag.options }
746
+ : { symbol_name: tag.symbolName };
747
+ await invoke("ADS", "register_symbol", invokeOptions);
748
+ } catch (ex) {
749
+ console.error(`Failed to register symbol ${tag.symbolName}: ${ex}`);
750
+ }
751
+ }
752
+ }
753
+ else {
754
+ // For all other modules, use autocore-server standard subscription logic.
755
+ // This will be standardized in future versions of autocore-server, but for
756
+ // now we use the original autocore-server protocol system.
757
+ for ( const tag of tagList ) {
758
+ await invoke (domain, "subscribe", {data_filter: tag.symbolName});
759
+ }
760
+ }
761
+
762
+ // ──────────────────────────────────────────────────────────
763
+ // STEP 2b: Set up LOCAL EventEmitter subscriptions
764
+ // ──────────────────────────────────────────────────────────
765
+ // This registers callbacks in the browser's EventEmitter.
766
+ // When a broadcast arrives via WebSocket, it will be dispatched
767
+ // to these callbacks.
768
+ //
769
+ // IMPORTANT: This is CLIENT-SIDE ONLY!
770
+ // It does NOT tell the server to start sending updates.
771
+ // For ADS, register_symbol (above) handles that.
772
+ // For other domains, we need to send a "subscribe" command.
773
+ //
774
+ // Topic format: "DOMAIN/symbolName"
775
+ // Example: "MODBUS/simulator.holding_0"
776
+ for (const tag of tagList) {
777
+ const id = subscribe(`${domain}/${tag.symbolName}`, (data) => {
778
+ if (!mounted) return;
779
+ handleTagUpdate(tag, data?.value);
780
+ });
781
+ subscriptions.push(id);
782
+ }
783
+
784
+ // ──────────────────────────────────────────────────────────
785
+ // STEP 2c: Eager reads for non-ADS domains
786
+ // ──────────────────────────────────────────────────────────
787
+ // For domains other than ADS, we do a one-time read to get
788
+ // initial values. ADS has its own "refresh" mechanism.
789
+ if (eagerRead && domain.toUpperCase() !== "ADS") {
790
+ await eagerPullNonADS(domain, tagList, { concurrency: 4, minDelayMs: 20, jitterMs: 40 });
791
+ }
792
+ }
793
+
794
+ // ──────────────────────────────────────────────────────────────
795
+ // STEP 3: ADS one-shot refresh
796
+ // ──────────────────────────────────────────────────────────────
797
+ // Tells the ADS server to immediately publish current values for
798
+ // all registered symbols. This gives us initial values quickly.
799
+ if (eagerRead) {
800
+ await invoke("ADS", "refresh", {});
801
+ }
802
+ } finally {
803
+ // Mark loading complete (with small delay for UI smoothness)
804
+ if (mounted) setTimeout(() => mounted && setIsLoading(false), 100);
805
+ }
806
+ };
807
+
808
+ /**
809
+ * Wrapper to ensure registerAndSubscribe only runs once.
810
+ * Uses startedRef to prevent duplicate initialization from React strict mode
811
+ * or rapid re-renders.
812
+ */
813
+ const safeRegister = async () => {
814
+ if (!mounted || startedRef.current) return;
815
+ startedRef.current = true;
816
+ try { await registerAndSubscribe(); }
817
+ finally { if (mounted) setTimeout(() => mounted && setIsLoading(false), 100); }
818
+ };
819
+
820
+ // ──────────────────────────────────────────────────────────────────────
821
+ // INITIALIZATION: Wait for WebSocket connection before registering
822
+ // ──────────────────────────────────────────────────────────────────────
823
+ if (!isConnected()) {
824
+ // Not connected yet - wait for connection event
825
+ const id = subscribe("HUB/connected", () => {
826
+ unsubscribe(id);
827
+ void safeRegister();
828
+ });
829
+ subscriptions.push(id);
830
+ } else {
831
+ // Already connected - register immediately
832
+ void safeRegister();
833
+ }
834
+
835
+ // ──────────────────────────────────────────────────────────────────────
836
+ // CLEANUP: Unsubscribe from all local subscriptions on unmount
837
+ // ──────────────────────────────────────────────────────────────────────
838
+ return () => {
839
+ mounted = false;
840
+ subscriptions.forEach(unsubscribe);
841
+ startedRef.current = false;
842
+ };
843
+ }, [subscribe, unsubscribe, isConnected, invoke, eagerRead, tags, pullServerScales, eagerPullNonADS, handleTagUpdate]);
844
+
845
+ /**
846
+ * Memoized list of scale configurations that have server-side storage.
847
+ * Used to set up live subscriptions for scale changes (e.g., when another
848
+ * user changes unit preferences).
849
+ */
850
+ const scaleServerSubs = useMemo(
851
+ () =>
852
+ Object.entries(scales)
853
+ .filter(([, cfg]) => cfg.serverTag)
854
+ .map(([scaleName, cfg]) => ({
855
+ scaleName,
856
+ domain: cfg.serverTag!.domain,
857
+ symbolName: cfg.serverTag!.symbolName,
858
+ })),
859
+ [scales]
860
+ );
861
+
862
+ /**
863
+ * ==========================================================================
864
+ * SCALE BROADCAST SUBSCRIPTIONS
865
+ * ==========================================================================
866
+ *
867
+ * This effect sets up live subscriptions to server-stored scale values.
868
+ * When a scale changes on the server (e.g., another user switches from mm to inches),
869
+ * this subscription receives the update and recomputes all affected display values.
870
+ *
871
+ * Note: This is separate from pullServerScales() which does a one-time read.
872
+ * This effect keeps the UI in sync with server changes in real-time.
873
+ */
874
+ useEffect(() => {
875
+ let mounted = true;
876
+ const subs: number[] = [];
877
+
878
+ for (const { scaleName, domain, symbolName } of scaleServerSubs) {
879
+ // Subscribe to broadcasts for this scale's server tag
880
+ const id = subscribe(`${domain}/${symbolName}`, (data) => {
881
+ if (!mounted) return;
882
+ const v = data?.value;
883
+ // Validate the received data has the expected shape
884
+ if (v && typeof v === "object" && typeof (v as any).scale === "number") {
885
+ const { scale, label } = v as { scale: number; label?: string };
886
+ // Update local scale state
887
+ setScaleValues(prev => ({
888
+ ...prev,
889
+ [scaleName]: {
890
+ ...prev[scaleName],
891
+ scale,
892
+ label: label ?? prev[scaleName]?.label ?? "---",
893
+ },
894
+ }));
895
+ // Recompute all display values that use this scale
896
+ rescaleFromRaw(scaleName);
897
+ }
898
+ });
899
+ subs.push(id);
900
+ }
901
+
902
+ // Cleanup subscriptions on unmount
903
+ return () => {
904
+ mounted = false;
905
+ subs.forEach(unsubscribe);
906
+ };
907
+ }, [subscribe, unsubscribe, scaleServerSubs, rescaleFromRaw]);
908
+
909
+ /**
910
+ * Writes a value to a tag on the server.
911
+ *
912
+ * This function:
913
+ * 1. Looks up the tag configuration by name
914
+ * 2. Converts display value → server value (inverse scaling, codec encoding)
915
+ * 3. Builds the appropriate payload for the tag's domain
916
+ * 4. Sends the write command to the server
917
+ *
918
+ * **Domain-specific payloads:**
919
+ * - ADS: `{ symbol_name: "...", value: ... }`
920
+ * - GNV: `{ group: "ux", key: "...", value: ... }`
921
+ * - Other: `{ key: "...", value: ... }`
922
+ *
923
+ * @param tagName - The tag name (as defined in TagConfig, not symbolName)
924
+ * @param displayValue - The value in display units (will be inverse-scaled)
925
+ *
926
+ * @example
927
+ * // Write 10 inches to a position tag (scale converts to 254mm for server)
928
+ * await write("pressPosition", 10.0);
929
+ *
930
+ * @example
931
+ * // Write a boolean
932
+ * await write("isMotorsOn", true);
933
+ */
934
+ const write = useCallback(
935
+ async (tagName: string, displayValue: unknown) => {
936
+ // Find the tag configuration
937
+ const cfg = tags.find((t) => t.tagName === tagName);
938
+ if (!cfg) {
939
+ console.error(`write(): unknown tag '${tagName}'`);
940
+ return;
941
+ }
942
+
943
+ // Convert display value to server value (inverse scaling/codec)
944
+ const serverValue = toServer(cfg, displayValue);
945
+
946
+ // Build domain-specific payload
947
+ let payload: any;
948
+ const dom = cfg.domain.toUpperCase();
949
+ if (dom === "ADS") {
950
+ // ADS uses symbol_name
951
+ payload = { symbol_name: cfg.symbolName, value: serverValue };
952
+ } else if (dom === "GNV") {
953
+ // GNV uses group + key
954
+ payload = { group: "ux", key: cfg.symbolName, value: serverValue };
955
+ } else {
956
+ // Other domains (MODBUS, DATASTORE, etc.) use key
957
+ payload = { key: cfg.symbolName, value: serverValue };
958
+ }
959
+
960
+ // Send to server
961
+ await invoke(cfg.domain, "write_value", payload);
962
+ },
963
+ [tags, invoke, toServer]
964
+ );
965
+
966
+ /**
967
+ * Pulses a boolean tag: writes true, waits 300ms, then writes false.
968
+ *
969
+ * This is commonly used for "momentary" buttons or triggers in automation
970
+ * systems where you need to send a rising edge rather than toggle state.
971
+ *
972
+ * **Only works with boolean tags** - logs a warning if called on other types.
973
+ *
974
+ * @param tagName - The tag name (must be a boolean type tag)
975
+ *
976
+ * @example
977
+ * // Pulse a "start cycle" button
978
+ * await tap("cycleStart");
979
+ * // Server receives: true at t=0, false at t=300ms
980
+ *
981
+ * @example
982
+ * // In a React component
983
+ * const { tap } = useAutoCoreTag("motorsEnable");
984
+ * return <button onClick={() => tap()}>Enable Motors</button>;
985
+ */
986
+ const tap = useCallback(
987
+ async (tagName: string) => {
988
+ // Find the tag configuration
989
+ const cfg = tags.find((t) => t.tagName === tagName);
990
+ if (!cfg) {
991
+ console.error(`tap(): unknown tag '${tagName}'`);
992
+ return;
993
+ }
994
+ // Only boolean tags can be tapped
995
+ if (cfg.valueType !== "boolean") {
996
+ console.warn(`tap(): tag '${tagName}' is not a boolean type`);
997
+ return;
998
+ }
999
+
1000
+ // Helper to build domain-specific payload
1001
+ const dom = cfg.domain.toUpperCase();
1002
+ const mkPayload = (val: boolean) =>
1003
+ dom === "ADS"
1004
+ ? { symbol_name: cfg.symbolName, value: val }
1005
+ : dom === "GNV"
1006
+ ? { group: "ux", key: cfg.symbolName, value: val }
1007
+ : { key: cfg.symbolName, value: val };
1008
+
1009
+ // Pulse: true → wait → false
1010
+ await invoke(cfg.domain, "write_value", mkPayload(true));
1011
+ await sleep(300);
1012
+ await invoke(cfg.domain, "write_value", mkPayload(false));
1013
+ },
1014
+ [tags, invoke]
1015
+ );
1016
+
1017
+ /**
1018
+ * Updates a scale factor and label, optionally persisting to the server.
1019
+ *
1020
+ * This function:
1021
+ * 1. Validates the scale name exists
1022
+ * 2. If the scale has a serverTag, writes the new configuration to the server
1023
+ * (so other clients and future sessions use the same units)
1024
+ * 3. Updates local scale state
1025
+ * 4. Recomputes all affected display values from raw
1026
+ *
1027
+ * **Server format:** `{ name: "position", scale: 0.03937, label: "in" }`
1028
+ *
1029
+ * @param scaleName - The scale group name (e.g., "position", "load")
1030
+ * @param newScale - The new scale factor to apply (raw * scale = display)
1031
+ * @param newLabel - The new unit label to display (e.g., "in", "mm", "lbs")
1032
+ *
1033
+ * @example
1034
+ * // Switch position display from mm to inches
1035
+ * await updateScale("position", 0.03937, "in");
1036
+ *
1037
+ * @example
1038
+ * // In a React component with unit selector
1039
+ * const { scales, updateScale } = useScales();
1040
+ * return (
1041
+ * <select onChange={(e) => {
1042
+ * if (e.target.value === "mm") updateScale("position", 1.0, "mm");
1043
+ * if (e.target.value === "in") updateScale("position", 0.03937, "in");
1044
+ * }}>
1045
+ * <option value="mm">Millimeters</option>
1046
+ * <option value="in">Inches</option>
1047
+ * </select>
1048
+ * );
1049
+ */
1050
+ const updateScale = useCallback(
1051
+ async (scaleName: string, newScale: number, newLabel: string) => {
1052
+ const cfg = scaleValues[scaleName];
1053
+ if (!cfg) {
1054
+ console.error(`Scale '${scaleName}' not found`);
1055
+ return;
1056
+ }
1057
+
1058
+ // If scale has a server tag, persist to server for other clients/sessions
1059
+ if (cfg.serverTag) {
1060
+ await invoke(cfg.serverTag.domain, "write_value", {
1061
+ group: "ux",
1062
+ key: cfg.serverTag.symbolName,
1063
+ value: { name: scaleName, scale: newScale, label: newLabel },
1064
+ });
1065
+ }
1066
+
1067
+ // Update local scale state
1068
+ setScaleValues(prev => ({
1069
+ ...prev,
1070
+ [scaleName]: { ...prev[scaleName], scale: newScale, label: newLabel },
1071
+ }));
1072
+
1073
+ // Recompute all display values that use this scale (from raw values)
1074
+ rescaleFromRaw(scaleName);
1075
+ },
1076
+ [scaleValues, invoke, rescaleFromRaw]
1077
+ );
1078
+
1079
+ /**
1080
+ * ==========================================================================
1081
+ * DISPLAY VALUES RECOMPUTATION EFFECT
1082
+ * ==========================================================================
1083
+ *
1084
+ * This effect ensures display values stay in sync when:
1085
+ * 1. Component mounts with initial/seeded raw values
1086
+ * 2. Scale factors change (scaleValues dependency)
1087
+ * 3. Tag configuration changes (tags dependency)
1088
+ *
1089
+ * It iterates all tags and recomputes display values from raw using the
1090
+ * current scale factors. This is a safety net that catches edge cases
1091
+ * where individual updates might have been missed.
1092
+ *
1093
+ * Note: Individual updates via handleTagUpdate are more efficient for
1094
+ * normal operation. This effect handles bulk recomputation scenarios.
1095
+ */
1096
+ useEffect(() => {
1097
+ // Recompute every tag's display value from its raw value
1098
+ setValues(prev => {
1099
+ const next = { ...prev };
1100
+ for (const tag of tags) {
1101
+ const raw = rawRef.current[tag.tagName];
1102
+ if (raw === undefined) continue;
1103
+ const disp = toDisplay(tag, raw);
1104
+ // Only update if value actually changed (avoid unnecessary re-renders)
1105
+ if (next[tag.tagName] !== disp) next[tag.tagName] = disp;
1106
+ }
1107
+ return next;
1108
+ });
1109
+ }, [tags, toDisplay, scaleValues]);
1110
+
1111
+ /**
1112
+ * Memoized context value to prevent unnecessary re-renders.
1113
+ * Only recreates when one of the dependencies actually changes.
1114
+ */
1115
+ const ctxValue = useMemo<BaseContextValue<VMapRuntime>>(
1116
+ () => ({
1117
+ values, // Display values (scaled/decoded) - what the UI shows
1118
+ rawValues, // Raw controller values - source of truth for scaling
1119
+ isLoading, // True until initial setup completes
1120
+ write, // Function to write a value to a tag
1121
+ tap, // Function to pulse a boolean tag
1122
+ scales: scaleValues, // Current scale configurations
1123
+ updateScale, // Function to change scale factor/label
1124
+ }),
1125
+ [values, rawValues, isLoading, write, tap, scaleValues, updateScale]
1126
+ );
1127
+
1128
+ /**
1129
+ * Render the context provider with all state and functions available
1130
+ * to child components via useContext(AutoCoreTagContext) or the typed hooks.
1131
+ */
1132
+ return (
1133
+ <AutoCoreTagContext.Provider value={ctxValue}>
1134
+ {children}
1135
+ </AutoCoreTagContext.Provider>
1136
+ );
1137
+ };