Building a Web HMI
HMI Overview
Every AutoCore project includes a www/ directory that contains a React + TypeScript web application. This web app connects to the server via WebSocket and can:
- Display live variable values
- Send commands (write variables, trigger actions)
- Show logs and status information
The web HMI is served directly by the AutoCore server. After deploying, you open it in any web browser.
The AutoCore React Library
The @adcops/autocore-react library provides React hooks for connecting to the AutoCore server and working with variables. The project template includes this library pre-configured.
Key hooks:
| Hook | Purpose |
|---|---|
useAutoCoreConnection() | Connect to the server WebSocket |
useVariable(name) | Subscribe to a variable and get its live value |
useCommand() | Send commands to the server |
Creating a Simple Dashboard
Here is a basic HMI that shows the motor speed and provides start/stop controls. Edit www/src/App.tsx:
import React from 'react';
import { useVariable, useCommand } from './AutoCore';
import { Button } from 'primereact/button';
import { Knob } from 'primereact/knob';
function App() {
// Subscribe to live variable values
const motorRunning = useVariable<boolean>('machine_running');
const speedActual = useVariable<number>('motor_speed_actual');
const speedSetpoint = useVariable<number>('motor_speed_setpoint');
// Command hook for writing variables
const sendCommand = useCommand();
const handleStart = () => {
sendCommand('gm.write', { name: 'machine_running', value: true });
};
const handleStop = () => {
sendCommand('gm.write', { name: 'machine_running', value: false });
};
const handleSpeedChange = (rpm: number) => {
sendCommand('gm.write', { name: 'motor_speed_setpoint', value: rpm });
};
return (
<div style={{ padding: '2rem' }}>
<h1>Motor Control</h1>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'center' }}>
<div>
<h3>Speed</h3>
<Knob
value={speedActual?.value ?? 0}
max={2000}
readOnly
valueTemplate="{value} RPM"
size={150}
/>
</div>
<div>
<h3>Setpoint</h3>
<Knob
value={speedSetpoint?.value ?? 0}
max={2000}
onChange={(e) => handleSpeedChange(e.value)}
valueTemplate="{value} RPM"
size={150}
/>
</div>
<div>
<h3>Controls</h3>
<Button
label="Start"
icon="pi pi-play"
onClick={handleStart}
disabled={motorRunning?.value === true}
severity="success"
style={{ marginRight: '1rem' }}
/>
<Button
label="Stop"
icon="pi pi-stop"
onClick={handleStop}
disabled={motorRunning?.value !== true}
severity="danger"
/>
</div>
</div>
<p>
Status: {motorRunning?.value ? 'RUNNING' : 'STOPPED'}
</p>
</div>
);
}
export default App;
Subscribing to Live Variable Updates
When you use useVariable(name), the library automatically subscribes to that variable via WebSocket. The value updates in real time whenever the control program changes it — there is no polling.
Under the hood, the library sends a CommandMessage to the server using the topic-based protocol:
{
"transaction_id": 1,
"topic": "gm.motor_speed_actual",
"message_type": 4,
"data": {}
}
The message_type values correspond to operations: 2 = Read, 3 = Write, 4 = Subscribe, 5 = Unsubscribe. The topic field is the FQDN of the resource (e.g., gm.motor_speed_actual).
The server then pushes updates whenever the value changes:
{
"transaction_id": 0,
"topic": "gm.motor_speed_actual",
"message_type": 6,
"data": 1247.5,
"success": true
}
Here message_type: 6 is a Broadcast — an unsolicited push from the server.
Update-rate cap (~30 FPS by default)
Even when the controller scan rate is in the kilohertz range, the React
HMI only updates at roughly 30 FPS. <AutoCoreTagProvider> coalesces
incoming broadcasts per tag (latest-value-wins) and applies them in a
single batched render at most once every flushIntervalMs (default
33). Without this cap, a 4 kHz scan with ten fast-changing tags would
fire ~40,000 React reconciliations per second — Firefox in particular
visibly stalls under that load.
To override the cap (e.g., 60 FPS for a kiosk on Chrome, or 0 to
disable throttling and apply every broadcast immediately):
<AutoCoreTagProvider tags={acTagSpec} flushIntervalMs={16}>
...
</AutoCoreTagProvider>
The cap only affects React reconciliation pacing — the wire still delivers every change-of-value broadcast. To also reduce wire bandwidth (relevant on weak WiFi or remote access over Tailscale), see the connection-level rate cap covered in the server reference.
Sending Commands from the HMI
To write a variable value:
sendCommand('gm.motor_speed_setpoint', { value: 1500 });
To read a variable on demand (instead of subscribing):
const result = await sendCommand('gm.motor_speed_actual', {});
console.log(result.value);
To send a command to an external module:
sendCommand('modbus.status', {});
sendCommand('ethercat.get_state', { slave: 'ClearPath_0' });
Servelet Hooks
@adcops/autocore-react/hooks/useServeletData provides three typed hooks on top of the servelet conventions described in Chapter 4. Each one does the same three things that application code usually rewrites by hand: (1) read the current value on mount, (2) subscribe to broadcasts so the value stays live, (3) expose write() / refresh(). Use them instead of calling invoke() by hand whenever the key is known at render time.
useMemoryStore(key, default?) — volatile runtime cache
Reads from and writes to the memorystore domain. Good for UI state you want to survive a page reload but not a server restart — e.g. the last-open tab, a cached editor buffer, scratch data between tabs.
import { useMemoryStore } from '@adcops/autocore-react/hooks/useServeletData';
const EditorTab: React.FC = () => {
const { value: xml, write: saveXml, isLoading } = useMemoryStore<string>('ux://editor-current-xml', '');
if (isLoading) return <Spinner />;
return (
<textarea
value={xml ?? ''}
onChange={e => saveXml(e.target.value)}
/>
);
};
Wire topic: memorystore.{key}. Writes broadcast on the same topic, so any other component hooked onto the same key sees the new value without a second fetch.
useGnv(group, key, default?) — persistent configuration
Reads/writes a single key under the Global Non-Volatile store. Use for settings that must outlive a server restart — unit preferences, calibration constants, display labels. The server enforces group-then-key addressing; the hook handles both.
import { useGnv } from '@adcops/autocore-react/hooks/useServeletData';
const UnitsPanel: React.FC = () => {
const { value: label, write: setLabel } = useGnv<string>('ux', 'position_label', 'mm');
const { value: scale, write: setScale } = useGnv<number>('ux', 'position_units', 1);
return (
<>
<input value={label ?? ''} onChange={e => setLabel(e.target.value)} />
<input
type="number"
value={scale ?? 1}
onChange={e => setScale(Number(e.target.value))}
/>
</>
);
};
Wire topic: gnv.{group}.{key}.
useServeletData(domain, key, default?) — escape hatch
The generic form behind useMemoryStore and useGnv. Use it when you need to hook into a servelet those specialized wrappers don’t cover (for example, a third-party servelet you wrote with the same Read/Write/Control convention).
import { useServeletData } from '@adcops/autocore-react/hooks/useServeletData';
const { value, write } = useServeletData<MyShape>('myservelet', 'some_key');
Wire topic: {domain}.{key}. The servelet on the other end must follow the standard conventions (Read/Write dispatched by message type, broadcast on the same topic after Write) for the hook’s subscription path to work.
When hooks aren’t the right fit
Hooks bind a component to a known, fixed key. Reach for invoke() directly when:
- The key is dynamic (e.g. the user types it into a form and it changes per action).
- You need a one-shot read or write that doesn’t also subscribe — an export handler, a click-to-delete.
- You’re using operations that don’t fit the simple Read/Write envelope (
MessageType.Controloperations likelist_files,create_group,delete; or application-level topics on ADS/MODBUS/PYTHON modules that still route by legacyfnamestrings).
// Dynamic file read
const { invoke } = useContext(EventEmitterContext);
const res = await invoke(`datastore.${fileName}`, MessageType.Read, {});
// List files (Control action)
const res = await invoke('datastore', MessageType.Control, {
action: 'list_files',
subdir: 'sequences',
options: { filter: '*.json' },
});
// Legacy fname dispatch (ADS, PYTHON, etc.)
await invoke('ADS.register_symbol', MessageType.Request, { symbol_name: 'GIO.fMotorSpeed' });
Deploying the HMI
Build and deploy the web HMI:
cd www
npm install # First time only
npm run build # Creates www/dist/
cd ..
acctl push www # Uploads dist/ to the server
Then open your browser to http://<server_ip>:8080 to see the HMI.
During development, you can run the HMI in development mode with hot reloading:
cd www
npm run dev
This starts a local dev server (usually at http://localhost:5173). You will need to configure the WebSocket URL to point to your AutoCore server — check www/src/AutoCore.ts for the connection settings.