@vention/machine-logic-ui-sdk
v0.46.1
Published
Keywords
Readme
@vention/machine-logic-ui-sdk
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:
npm install @vention/machine-logic-ui-sdk@<pinned-version>What the HMI needs alongside it:
- React 17 or 19. The package's
peerDependenciesacceptsreact^17.0.2 || ^19.3.0. The SDK is type-checked against both versions (thetypecheck-react19target); its unit tests run on React 17. @mui/materialand@emotion/react. The package entry importstss-react/mui(used byVirtualKeyboard) at load time, so importing any export pulls it in.- A bundler that handles CSS imports, such as Vite. The entry imports
react-simple-keyboard/build/css/index.css.
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.
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.
Live data over MQTT
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.
versiondefaults to"v2"and picks which broker address to derive, throughgetMqttWsUrl. On the edge both use an unencrypted websocket:"v2"connects to192.168.5.2:9001and"v3"connects to port9001on the host that serves the HMI. In the cloud iframe both use the samewss://URL:wss://<the HMI's host><mqttPath>when the host page gives anmqttPathquery parameter that starts with/, else a URL derived from the iframe URL.urlsets the broker websocket URL yourself. When set,versionand the URL detection are skipped. Use it for standalone development, for examplews://localhost:9001.
From projects/machine-code/apps/ai-operator/frontend/src/main.tsx, trimmed:
import { StrictMode } from "react"
import ReactDOM from "react-dom"
import { MqttProvider } from "@vention/machine-logic-ui-sdk"
import App from "./app/app"
ReactDOM.render(
<StrictMode>
<MqttProvider version="v3">
<App />
</MqttProvider>
</StrictMode>,
document.getElementById("root")
)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:
<MqttProvider version="v3" url={import.meta.env.VITE_MQTT_WS_URL}>
<App />
</MqttProvider>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.
topicmust be an exact topic. The hook compares each incoming topic to this string with===, so a wildcard filter such asoperation/bins/+subscribes at the broker but never updates the returned value. UseMqttClientfor wildcards.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.- 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. - Nothing subscribes until the provider's
connectionStatusis"connected". After a reconnect the hooks subscribe again.
From projects/machine-code/apps/ai-operator/frontend/src/app/contexts/operation-context.tsx, trimmed:
import { useEffect, useState } from "react"
import { useMqttSubscription } from "@vention/machine-logic-ui-sdk"
interface BinStatus {
cardState: "normal" | "missing"
pickedCount?: number
averageCycleTime?: number
}
export const OperationStatus = () => {
const [isOperationRunning, setIsOperationRunning] = useState(false)
const operationActive = useMqttSubscription<boolean>({
topic: "operation/operationActive",
unsubscribeOnUnmount: true,
})
const bin1Status = useMqttSubscription<BinStatus>({
topic: "operation/bins/1",
unsubscribeOnUnmount: true,
})
useEffect(
function handleOperationActiveFromMqtt() {
if (operationActive === undefined) return
setIsOperationRunning(operationActive)
},
[operationActive]
)
return (
<p>
{isOperationRunning ? "Running" : "Stopped"}, bin 1 picked {bin1Status?.pickedCount ?? 0}
</p>
)
}mqttContext is the React context the provider fills. Read it with useContext(mqttContext):
client: the provider's mqtt.jsMqttClient(not this package'sMqttClientclass), ornullbefore the first connection. Use it to publish.connectionStatus: the provider's connection state.subscribedTopics,addSubscribedTopic,removeSubscribedTopic: the provider's bookkeeping foruseMqttSubscription. An application does not need them.
import { useContext } from "react"
import { mqttContext } from "@vention/machine-logic-ui-sdk"
export const StopButton = () => {
const { client, connectionStatus } = useContext(mqttContext)
return (
<button disabled={connectionStatus !== "connected"} onClick={() => client?.publish("operation/command", JSON.stringify({ action: "stop" }))}>
Stop
</button>
)
}Typing topics
The MqttPayloadByTopic type looks up a topic's payload type in a map of topics. Declare the application's topics once as that map:
import { MqttPayloadByTopic, useMqttSubscription } from "@vention/machine-logic-ui-sdk"
type OperationTopics = {
"operation/operationActive": boolean
"operation/bins/1": { cardState: "normal" | "missing"; pickedCount?: number }
}
type BinStatus = MqttPayloadByTopic<OperationTopics, "operation/bins/1">
export function useOperationTopic<Topic extends keyof OperationTopics & string>(topic: Topic) {
return useMqttSubscription<MqttPayloadByTopic<OperationTopics, Topic>>({ topic, unsubscribeOnUnmount: true })
}
// useOperationTopic("operation/operationActive") returns boolean | undefinedThe raw client
MqttClient is a small wrapper over mqtt.js for code outside React, or for wildcard subscriptions.
- The constructor connects immediately. Its optional callback runs on every successful connection, including after a reconnect.
connectis what the constructor calls. Calling it again opens a second connection; an application does not need to.subscribe(topic, regex, callback)subscribes totopicat the broker (the MQTT wildcards+and#are allowed) and callscallbackfor each incoming message whose topic matchesregex.unsubscribe(topic, regex, callback)removes that callback, and unsubscribestopicat the broker once no remaining callback uses the sameregex.publishpublishes a string, not retained unless you passretain. Serialize objects withJSON.stringifyyourself.- A
Callbackreceives the topic and the raw payload as a string; the payload is not parsed.
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.
import { Callback, MqttClient, getMqttWsUrl } from "@vention/machine-logic-ui-sdk"
const BIN_TOPIC_FILTER = "operation/bins/+"
const BIN_TOPIC_PATTERN = /^operation\/bins\/\d+$/
const onBinStatus: Callback = (topic, message) => {
const status = JSON.parse(message)
console.log(topic, status.pickedCount)
}
export async function watchBins(): Promise<MqttClient> {
const client = new MqttClient(await getMqttWsUrl("v3"), () => console.log("MQTT connected"))
client.subscribe(BIN_TOPIC_FILTER, BIN_TOPIC_PATTERN, onBinStatus)
client.publish("operation/command", JSON.stringify({ action: "start" }))
return client
}
// Later: client.unsubscribe(BIN_TOPIC_FILTER, BIN_TOPIC_PATTERN, onBinStatus)URL helpers:
connectToMqttAndGetClientbuilds a client onawait getMqttWsUrl(), which is the"v2"broker address. It takes no arguments; for"v3"constructnew MqttClient(await getMqttWsUrl("v3"))instead.getMqttWsUrlreturns the broker websocket URL forversion, which defaults to"v2". WhenisOnEdge()is true it returns an unencrypted websocket URL:192.168.5.2:9001for"v2"and port9001on the current hostname for"v3". Otherwise, when the HMI's URL had anmqttPathquery parameter starting with/when the SDK loaded, set by the host page, it returnswss://<the HMI's host><mqttPath>. Otherwise it returnsgetMqttWsUrlGivenUiIframeUrl(window.location.href).getMqttWsUrlGivenUiIframeUrlderives the cloud broker URL from a remote iframe URL. It keeps the URL up to and including thedigital-twinpath segment, switcheshttps:towss:, then appends the path segment that followspassthroughand/mqtt:wss://<host>/.../digital-twin/<token>/mqtt.
Talking to the application's backend
These return plain strings to pass to fetch or to an RPC transport such as createConnectTransport({ baseUrl }) from @connectrpc/connect-web.
getExecutionEngineHttpUrlreturns the execution engine's base URL:http://<current hostname>:3100on the edge, otherwise everything inwindow.location.hrefbefore/machineCodeUi(the whole URL if that segment is absent).getMachineCodeProcessHttpUrltakes a process name and returns<execution engine URL>/v1/machineCode/services/<processName>, the execution engine's proxy to one of the application's processes.getMachineCodeApiBaseUrlreturnsgetMachineCodeProcessHttpUrl(processName)followed by/api.
import { getMachineCodeApiBaseUrl } from "@vention/machine-logic-ui-sdk"
export async function fetchStatus(): Promise<unknown> {
const response = await fetch(`${getMachineCodeApiBaseUrl("my-backend")}/status`)
return response.json()
}"my-backend" stands for the name the application's backend process is registered under, and /status for a route that backend serves.
Touch screens and the control center
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.
- A text layout covers
text,password,email,search,url,month,datetime-local,time, and<textarea>; a numeric layout coverstel,date, andweek. Other input types get no keyboard. type="number"is not supported: the keyboard logs a warning and changes the input totype="tel". Usetype="tel"for numeric fields.- Values are written through the native value setter and followed by
inputevents, so ReactonChangehandlers fire. Achangeevent is dispatched on blur if the value changed.
isTouchScreenDevice returns true when "ontouchstart" in window or navigator.maxTouchPoints > 0. Gate the keyboard on it:
import { ReactNode } from "react"
import { VirtualKeyboard, isTouchScreenDevice } from "@vention/machine-logic-ui-sdk"
export const AppLayout = ({ children }: { children: ReactNode }) => (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "auto" }}>
<main style={{ flexGrow: 1 }}>{children}</main>
{isTouchScreenDevice() ? <VirtualKeyboard /> : null}
</div>
)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.
The HMI runs in an iframe. These post a message to window.parent for the host page to act on:
closeCustomUiposts"closeCustomUi", which asks the host to close the HMI.navigateControlCenterposts"navigateToRemoteSupport"for"remoteSupport"or"navigateToControlCenterHomepage"for"controlCenterHomepage", which moves the host to the remote support page or the control center homepage.
import { closeCustomUi, navigateControlCenter } from "@vention/machine-logic-ui-sdk"
export const ExitBar = () => (
<nav>
<button onClick={() => navigateControlCenter("remoteSupport")}>Get help</button>
<button onClick={closeCustomUi}>Exit</button>
</nav>
)Outside an iframe, window.parent is the page itself, so these messages reach nothing unless the page listens for them.
