@vention/machine-logic-ui-sdk 0.43.14 → 0.43.16
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.
- package/README.md +239 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,242 @@
|
|
|
1
|
-
# machine-logic-ui-sdk
|
|
1
|
+
# @vention/machine-logic-ui-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser helpers for a MachineLogic application's custom HMI, the React app in the application's `customui/` folder that the pendant and the control center load in an iframe. It gives the HMI live data from the machine's MQTT broker, the URLs of the execution engine and of the application's own backend processes, an on-screen keyboard for touch pendants, and the messages that close the HMI or move the control center to another page. Install it at the exact version the application pins in `customui/package.json`:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```bash
|
|
6
|
+
npm install @vention/machine-logic-ui-sdk@<pinned-version>
|
|
7
|
+
```
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
What the HMI needs alongside it:
|
|
10
|
+
|
|
11
|
+
- React 17. The package's `peerDependencies` pins `react` to `17.0.2`.
|
|
12
|
+
- `@mui/material` and `@emotion/react`. The package entry imports `tss-react/mui` (used by `VirtualKeyboard`) at load time, so importing any export pulls it in.
|
|
13
|
+
- A bundler that handles CSS imports, such as Vite. The entry imports `react-simple-keyboard/build/css/index.css`.
|
|
14
|
+
|
|
15
|
+
Inside the platform-products monorepo the same source is also reachable as `@ventionco/machine-logic-ui-sdk` through a TypeScript path alias. Applications install the published `@vention/machine-logic-ui-sdk`.
|
|
16
|
+
|
|
17
|
+
The parameter, prop and return types of every export are in the package's type declarations, which your editor and `tsc` read. Look there for signatures; this README covers only behaviour that the types can't show.
|
|
18
|
+
|
|
19
|
+
## Live data over MQTT
|
|
20
|
+
|
|
21
|
+
Wrap the app once in `MqttProvider`. It opens one MQTT-over-websocket connection that every `useMqttSubscription` call below it shares, and reconnects on its own.
|
|
22
|
+
|
|
23
|
+
- `version` defaults to `"v2"` and picks which broker address to derive, through `getMqttWsUrl`. On the edge both use an unencrypted websocket: `"v2"` connects to `192.168.5.2:9001` and `"v3"` connects to port `9001` on the host that serves the HMI. In the cloud iframe both derive the same `wss://` URL from the iframe URL.
|
|
24
|
+
- `url` sets the broker websocket URL yourself. When set, `version` and the URL detection are skipped. Use it for standalone development, for example `ws://localhost:9001`.
|
|
25
|
+
|
|
26
|
+
From `projects/machine-code/apps/ai-operator/frontend/src/main.tsx`, trimmed:
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { StrictMode } from "react"
|
|
30
|
+
import ReactDOM from "react-dom"
|
|
31
|
+
import { MqttProvider } from "@vention/machine-logic-ui-sdk"
|
|
32
|
+
|
|
33
|
+
import App from "./app/app"
|
|
34
|
+
|
|
35
|
+
ReactDOM.render(
|
|
36
|
+
<StrictMode>
|
|
37
|
+
<MqttProvider version="v3">
|
|
38
|
+
<App />
|
|
39
|
+
</MqttProvider>
|
|
40
|
+
</StrictMode>,
|
|
41
|
+
document.getElementById("root")
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For development outside the iframe, pass the broker yourself, for example with `VITE_MQTT_WS_URL=ws://localhost:9001`. Left unset, the provider falls back to detection:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
<MqttProvider version="v3" url={import.meta.env.VITE_MQTT_WS_URL}>
|
|
49
|
+
<App />
|
|
50
|
+
</MqttProvider>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Read a topic with `useMqttSubscription`. It returns the latest message on that topic, or `undefined` until the first one arrives. The broker subscription is made once per topic, so only the first component to read a topic gets its retained message; a component that mounts later for the same topic returns `undefined` until the next publish.
|
|
54
|
+
|
|
55
|
+
- `topic` must be an exact topic. The hook compares each incoming topic to this string with `===`, so a wildcard filter such as `operation/bins/+` subscribes at the broker but never updates the returned value. Use `MqttClient` for wildcards.
|
|
56
|
+
- `unsubscribeOnUnmount`, off by default, unsubscribes at the broker when the component unmounts. The broker subscription is shared per topic across the provider, so this also stops the topic for any other component still reading it. The cleanup also runs whenever the connection status changes, and the hook subscribes again once it's connected.
|
|
57
|
+
- Every payload goes through `JSON.parse`. Publish JSON on topics the HMI reads; a payload that is not valid JSON throws in the message handler. The type argument is an assertion, not a runtime check.
|
|
58
|
+
- Nothing subscribes until the provider's `connectionStatus` is `"connected"`. After a reconnect the hooks subscribe again.
|
|
59
|
+
|
|
60
|
+
From `projects/machine-code/apps/ai-operator/frontend/src/app/contexts/operation-context.tsx`, trimmed:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { useEffect, useState } from "react"
|
|
64
|
+
import { useMqttSubscription } from "@vention/machine-logic-ui-sdk"
|
|
65
|
+
|
|
66
|
+
interface BinStatus {
|
|
67
|
+
cardState: "normal" | "missing"
|
|
68
|
+
pickedCount?: number
|
|
69
|
+
averageCycleTime?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const OperationStatus = () => {
|
|
73
|
+
const [isOperationRunning, setIsOperationRunning] = useState(false)
|
|
74
|
+
|
|
75
|
+
const operationActive = useMqttSubscription<boolean>({
|
|
76
|
+
topic: "operation/operationActive",
|
|
77
|
+
unsubscribeOnUnmount: true,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const bin1Status = useMqttSubscription<BinStatus>({
|
|
81
|
+
topic: "operation/bins/1",
|
|
82
|
+
unsubscribeOnUnmount: true,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
useEffect(
|
|
86
|
+
function handleOperationActiveFromMqtt() {
|
|
87
|
+
if (operationActive === undefined) return
|
|
88
|
+
setIsOperationRunning(operationActive)
|
|
89
|
+
},
|
|
90
|
+
[operationActive]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<p>
|
|
95
|
+
{isOperationRunning ? "Running" : "Stopped"}, bin 1 picked {bin1Status?.pickedCount ?? 0}
|
|
96
|
+
</p>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`mqttContext` is the React context the provider fills. Read it with `useContext(mqttContext)`:
|
|
102
|
+
|
|
103
|
+
- `client`: the provider's [mqtt.js](https://github.com/mqttjs/MQTT.js) `MqttClient` (not this package's `MqttClient` class), or `null` before the first connection. Use it to publish.
|
|
104
|
+
- `connectionStatus`: the provider's connection state.
|
|
105
|
+
- `subscribedTopics`, `addSubscribedTopic`, `removeSubscribedTopic`: the provider's bookkeeping for `useMqttSubscription`. An application does not need them.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { useContext } from "react"
|
|
109
|
+
import { mqttContext } from "@vention/machine-logic-ui-sdk"
|
|
110
|
+
|
|
111
|
+
export const StopButton = () => {
|
|
112
|
+
const { client, connectionStatus } = useContext(mqttContext)
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<button disabled={connectionStatus !== "connected"} onClick={() => client?.publish("operation/command", JSON.stringify({ action: "stop" }))}>
|
|
116
|
+
Stop
|
|
117
|
+
</button>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Typing topics
|
|
123
|
+
|
|
124
|
+
The `MqttPayloadByTopic` type looks up a topic's payload type in a map of topics. Declare the application's topics once as that map:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { MqttPayloadByTopic, useMqttSubscription } from "@vention/machine-logic-ui-sdk"
|
|
128
|
+
|
|
129
|
+
type OperationTopics = {
|
|
130
|
+
"operation/operationActive": boolean
|
|
131
|
+
"operation/bins/1": { cardState: "normal" | "missing"; pickedCount?: number }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type BinStatus = MqttPayloadByTopic<OperationTopics, "operation/bins/1">
|
|
135
|
+
|
|
136
|
+
export function useOperationTopic<Topic extends keyof OperationTopics & string>(topic: Topic) {
|
|
137
|
+
return useMqttSubscription<MqttPayloadByTopic<OperationTopics, Topic>>({ topic, unsubscribeOnUnmount: true })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// useOperationTopic("operation/operationActive") returns boolean | undefined
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## The raw client
|
|
144
|
+
|
|
145
|
+
`MqttClient` is a small wrapper over mqtt.js for code outside React, or for wildcard subscriptions.
|
|
146
|
+
|
|
147
|
+
- The constructor connects immediately. Its optional callback runs on every successful connection, including after a reconnect.
|
|
148
|
+
- `connect` is what the constructor calls. Calling it again opens a second connection; an application does not need to.
|
|
149
|
+
- `subscribe(topic, regex, callback)` subscribes to `topic` at the broker (the MQTT wildcards `+` and `#` are allowed) and calls `callback` for each incoming message whose topic matches `regex`.
|
|
150
|
+
- `unsubscribe(topic, regex, callback)` removes that callback, and unsubscribes `topic` at the broker once no remaining callback uses the same `regex`.
|
|
151
|
+
- `publish` publishes a string, not retained unless you pass `retain`. Serialize objects with `JSON.stringify` yourself.
|
|
152
|
+
- A `Callback` receives the topic and the raw payload as a string; the payload is not parsed.
|
|
153
|
+
|
|
154
|
+
Calls made before the first connection opens are queued and sent once it does. After a close, the client is disconnected for about a second before it reconnects; during that gap `publish` is dropped silently and `subscribe` never reaches the broker, and once reconnected the client does not re-send the broker subscriptions made on the old connection. For a long-lived React HMI, prefer `MqttProvider` and `useMqttSubscription`, which resubscribe after a reconnect.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { Callback, MqttClient, getMqttWsUrl } from "@vention/machine-logic-ui-sdk"
|
|
158
|
+
|
|
159
|
+
const BIN_TOPIC_FILTER = "operation/bins/+"
|
|
160
|
+
const BIN_TOPIC_PATTERN = /^operation\/bins\/\d+$/
|
|
161
|
+
|
|
162
|
+
const onBinStatus: Callback = (topic, message) => {
|
|
163
|
+
const status = JSON.parse(message)
|
|
164
|
+
console.log(topic, status.pickedCount)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function watchBins(): Promise<MqttClient> {
|
|
168
|
+
const client = new MqttClient(await getMqttWsUrl("v3"), () => console.log("MQTT connected"))
|
|
169
|
+
client.subscribe(BIN_TOPIC_FILTER, BIN_TOPIC_PATTERN, onBinStatus)
|
|
170
|
+
client.publish("operation/command", JSON.stringify({ action: "start" }))
|
|
171
|
+
return client
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Later: client.unsubscribe(BIN_TOPIC_FILTER, BIN_TOPIC_PATTERN, onBinStatus)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
URL helpers:
|
|
178
|
+
|
|
179
|
+
- `connectToMqttAndGetClient` builds a client on `await getMqttWsUrl()`, which is the `"v2"` broker address. It takes no arguments; for `"v3"` construct `new MqttClient(await getMqttWsUrl("v3"))` instead.
|
|
180
|
+
- `getMqttWsUrl` returns the broker websocket URL for `version`, which defaults to `"v2"`. When `isOnEdge()` is true it returns an unencrypted websocket URL: `192.168.5.2:9001` for `"v2"` and port `9001` on the current hostname for `"v3"`. Otherwise it returns `getMqttWsUrlGivenUiIframeUrl(window.location.href)`.
|
|
181
|
+
- `getMqttWsUrlGivenUiIframeUrl` derives the cloud broker URL from a remote iframe URL. It keeps the URL up to and including the `digital-twin` path segment, switches `https:` to `wss:`, then appends the path segment that follows `passthrough` and `/mqtt`: `wss://<host>/.../digital-twin/<token>/mqtt`.
|
|
182
|
+
|
|
183
|
+
## Talking to the application's backend
|
|
184
|
+
|
|
185
|
+
These return plain strings to pass to `fetch` or to an RPC transport such as `createConnectTransport({ baseUrl })` from `@connectrpc/connect-web`.
|
|
186
|
+
|
|
187
|
+
- `getExecutionEngineHttpUrl` returns the execution engine's base URL: `http://<current hostname>:3100` on the edge, otherwise everything in `window.location.href` before `/machineCodeUi` (the whole URL if that segment is absent).
|
|
188
|
+
- `getMachineCodeProcessHttpUrl` takes a process name and returns `<execution engine URL>/v1/machineCode/services/<processName>`, the execution engine's proxy to one of the application's processes.
|
|
189
|
+
- `getMachineCodeApiBaseUrl` returns `getMachineCodeProcessHttpUrl(processName)` followed by `/api`.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { getMachineCodeApiBaseUrl } from "@vention/machine-logic-ui-sdk"
|
|
193
|
+
|
|
194
|
+
export async function fetchStatus(): Promise<unknown> {
|
|
195
|
+
const response = await fetch(`${getMachineCodeApiBaseUrl("my-backend")}/status`)
|
|
196
|
+
return response.json()
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`"my-backend"` stands for the name the application's backend process is registered under, and `/status` for a route that backend serves.
|
|
201
|
+
|
|
202
|
+
## Touch screens and the control center
|
|
203
|
+
|
|
204
|
+
`VirtualKeyboard` is an on-screen keyboard component with no props. Render it once, near the root of the layout. It listens for focus on any input in the document and shows itself about 150 ms later; it hides when the user presses its submit key, or taps outside both the keyboard and any input. It renders nothing while hidden. While visible it is a `position: sticky` panel, 45% of the viewport tall, at the bottom of its parent, and it scrolls the focused input into view.
|
|
205
|
+
|
|
206
|
+
- A text layout covers `text`, `password`, `email`, `search`, `url`, `month`, `datetime-local`, `time`, and `<textarea>`; a numeric layout covers `tel`, `date`, and `week`. Other input types get no keyboard.
|
|
207
|
+
- `type="number"` is not supported: the keyboard logs a warning and changes the input to `type="tel"`. Use `type="tel"` for numeric fields.
|
|
208
|
+
- Values are written through the native value setter and followed by `input` events, so React `onChange` handlers fire. A `change` event is dispatched on blur if the value changed.
|
|
209
|
+
|
|
210
|
+
`isTouchScreenDevice` returns true when `"ontouchstart" in window` or `navigator.maxTouchPoints > 0`. Gate the keyboard on it:
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
import { ReactNode } from "react"
|
|
214
|
+
import { VirtualKeyboard, isTouchScreenDevice } from "@vention/machine-logic-ui-sdk"
|
|
215
|
+
|
|
216
|
+
export const AppLayout = ({ children }: { children: ReactNode }) => (
|
|
217
|
+
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "auto" }}>
|
|
218
|
+
<main style={{ flexGrow: 1 }}>{children}</main>
|
|
219
|
+
{isTouchScreenDevice() ? <VirtualKeyboard /> : null}
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`isOnEdge` returns true when `window.location.hostname` starts with `192.168` or `localhost`, meaning the HMI is served from the machine's network rather than through the cloud. The URL helpers above branch on it. A dev server on `localhost` also counts as the edge, which is why standalone development passes `MqttProvider` an explicit `url`.
|
|
225
|
+
|
|
226
|
+
The HMI runs in an iframe. These post a message to `window.parent` for the host page to act on:
|
|
227
|
+
|
|
228
|
+
- `closeCustomUi` posts `"closeCustomUi"`, which asks the host to close the HMI.
|
|
229
|
+
- `navigateControlCenter` posts `"navigateToRemoteSupport"` for `"remoteSupport"` or `"navigateToControlCenterHomepage"` for `"controlCenterHomepage"`, which moves the host to the remote support page or the control center homepage.
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
import { closeCustomUi, navigateControlCenter } from "@vention/machine-logic-ui-sdk"
|
|
233
|
+
|
|
234
|
+
export const ExitBar = () => (
|
|
235
|
+
<nav>
|
|
236
|
+
<button onClick={() => navigateControlCenter("remoteSupport")}>Get help</button>
|
|
237
|
+
<button onClick={closeCustomUi}>Exit</button>
|
|
238
|
+
</nav>
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Outside an iframe, `window.parent` is the page itself, so these messages reach nothing unless the page listens for them.
|