Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

HookPurpose
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.Control operations like list_files, create_group, delete; or application-level topics on ADS/MODBUS/PYTHON modules that still route by legacy fname strings).
// 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.