Generated banner for Matlab Build Industrial Hmi
Generated logo for Matlab Build Industrial Hmi

Matlab Build Industrial Hmi

MathWorksOfficialCreated by MathWorks

  • Skill
  • Workflow
  • Automation and integrations
  • BSD-2-Clause
  • No issues found

From the author

Build industrial-grade SCADA/HMI dashboards in MATLAB following industrial-HMI conventions (ISA-101-aligned): gray-field philosophy, alarms at source, write safeguards, fixed-range trends, drill-down layout. Produces a real App Designer app (.mlapp, or plain-text .m+.xml on R2026b+) by handing serialization to the matlab-build-app skill when available, and falls back to a programmatic .m app otherwise. Use when wrapping OPC UA / Modbus / MQTT / OSI PI / PI AF monitoring scripts into a live operator app, building plant overviews, designing operator dashboards, or any time a user asks for a "SCADA dashboard", "HMI", "plant dashboard", "operator screen", or "industrial monitoring app" in MATLAB. Trigger on: SCADA, HMI, industrial dashboard, plant overview, operator screen, uigauge, uilamp, alarm banner, gray-field, ISA-101, OPC UA dashboard, setpoint, write safeguards, alarm visualization, OSIsoft PI, AVEVA PI, PI Server, PI Data Archive, PI AF, PI Asset Framework, piclient, afclient.
— the description by MathWorks

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured workflow for creating operator-facing SCADA and HMI dashboards within MATLAB App Designer. It enforces industrial design standards, including gray-field color philosophy, alarm visualization at the data source, write safeguards for setpoints, and fixed-range trend configurations. The skill guides users through the process of wrapping monitoring scripts—such as those for OPC UA, Modbus, MQTT, or OSI PI—into functional operator screens, ensuring that the resulting applications adhere to established industrial monitoring patterns.

What it does

  • Generates industrial HMI layouts using gray-field color themes.
  • Configures alarm thresholds and persistent alarm banners for process monitoring.
  • Implements write safeguards with confirmation dialogs and visual feedback for setpoint adjustments.
  • Integrates live data streams from protocols like OPC UA, Modbus, MQTT, and OSI PI.
  • Produces a formal HMI specification document for review before application construction.
  • Delegates final app serialization to the matlab-build-app skill when available.

When to use it

  • When you need to wrap monitoring scripts into a live operator dashboard.
  • When building plant overviews or area-specific industrial monitoring screens.
  • When an application requires ISA-101-aligned alarm visualization and trend configuration.
  • When adding setpoint controls with safety-critical write confirmations to an existing app.

Advantages

  • Ensures consistency across industrial dashboards by following ISA-101 design patterns.
  • Reduces operator fatigue through standardized alarm and color conventions.
  • Provides a clear, reviewable specification process before building the application.
  • Supports integration with common industrial communication protocols.

Usage examples

“Create a SCADA dashboard for my OPC UA plant monitoring script.”
The agent will guide you through classifying your nodes, selecting appropriate widgets, and drafting an HMI specification for review before building the dashboard.
“How do I add an alarm banner to my operator screen?”
The agent will provide the pattern for implementing a persistent top-row alarm banner and configuring source-level alarm thresholds.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/matlab/matlab-build-industrial-hmi/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip matlab-build-industrial-hmi.zip -d .agents/skills

Documentation by MathWorks

Industrial HMI Builder

Build operator-facing SCADA/HMI dashboards in MATLAB App Designer that follow industrial-HMI conventions: gray-field color philosophy, alarms shown at the data source, write safeguards on every setpoint, fixed-range trends with threshold lines, and a drill-down layout (plant overview → area → detail).

When to Use

  • A user asks to "wrap this OPC UA / Modbus / MQTT / OSI PI / PI AF script into a SCADA dashboard / HMI / operator screen" — including scripts generated by the OPC UA Explorer or Modbus Explorer apps (opcuageneratedScript.mlx, modbusgeneratedScript.mlx)

  • Building a plant overview, area screen, or detail screen for industrial monitoring

  • Adding alarm visualization, setpoint writes, or live trends to an existing App Designer app

  • Reviewing or refactoring an HMI that uses gauges, lamps, or trend axes for process data

  • Any prompt mentioning ISA-101, gray-field, alarm banner, write confirm, setpoint, or "operator dashboard"

When NOT to Use

  • General App Designer mechanics (uigridlayout, callback wiring, lifecycle, matlab.apps.AppBase scaffolding) and serialization to a real App Designer file — this skill delegates those to matlab-build-app. See references/app-designer-handoff.md. Two things stay here and must not be routed away: gray-field theming (never hand color/dark-mode to matlab-apply-theme) and HMI trends (never hand them to matlab-build-chart).

  • Discovering OPC UA servers on a network, finding endpoint URLs, connecting or configuring security and certificate trust, or browsing the address space to find nodes for the first time — use matlab-use-opcua-client.

  • Non-process domains (consumer apps, lab utilities, scientific GUIs) — the conventions here are tailored to plant operators monitoring physical processes.

Must-Follow Rules

Widget & Layout
  • Gray-field background [0.78 0.78 0.78]; panels [0.86 0.86 0.86]; 80%+ of the screen neutral. Color is reserved for exception. See references/color-and-layout-rules.md.

  • Construct EVERY HMI uifigure with both 'Color', [0.78 0.78 0.78] and 'Theme', 'light'. Neither alone is enough: on a dark-themed desktop child widgets (uigauge, uiaxes, uitable, uieditfield) inherit dark defaults even when the figure colour is set, and 'light' alone yields bright white instead of gray-field. Code + rationale: references/color-and-layout-rules.md; failure mode: references/common-mistakes.md entry 24.

  • Quantity caps per screen: ≤ 6 gauges, ≤ 4 trend axes, ≤ 12 numeric readouts — beyond that, use a uitable — and ≤ 5 saturated-color elements that the design chooses. One gauge's ScaleColors counts as one element however many bands it has; the connection lamp's blue is comms state, not alarm state, and sits outside the budget. Fixed MATLAB chrome does not count (a LinearGauge needle draws saturated red at every value and no public property recolors it). Full rule: assets/hmi-spec-template.md §4.

  • Connection state is BLUE [0.4 0.6 1.0]. Green [0 0.6 0] is reserved for "operator must verify yes this is actively OK" — not for "comms up" or "running".

  • Command buttons/controls are neutral gray. Never a green Start / red Stop — buttons are always-visible chrome and carry no status, so coloring them spends the alarm colors and steals contrast from real alarms. Differentiate by label; show run/stopped state on a separate lamp. (Red E-Stop is the one convention-based exception.) See references/color-and-layout-rules.md.

  • Like-typed analogs in one panel use like widgets. Don't show one temperature as a gauge and another as a numeric label.

  • NEVER produce a dark background for an industrial HMI, regardless of the user request. ISA-101 has no dark variant; a dark field destroys color-as-exception (alarm reds and ambers stop popping against a near-black background, and operators desensitize). Always render gray-field ([0.78 0.78 0.78]) and reply with a one-sentence justification when the user asks for dark mode. This is not a default — it is a hard constraint.

  • Alarms at source AND in a banner. Keyed ScaleColors/ScaleColorLimits on every gauge with thresholds (Limits must cover every band — out-of-range bands are silently clipped); persistent top-row banner listing active alarms, color-coded by highest severity; latched state with an Acknowledge button — latching and its ACK control ship together or neither does. The normal band in ScaleColors is neutral gray [0.5 0.5 0.5] — never green. See references/alarm-patterns.md.

  • Never uialert/uiconfirm/msgbox for live process alarms — alarm-fatigue anti-pattern (ISA-18.2). If a popup is unavoidable, fire once on the rising edge with a non-blocking CloseFcn and an Acknowledge button.

  • Fixed trend YLim matching the node's spec range — never auto-scale by default. Provide a per-trend toggle for exploration. See references/trend-config-reference.md.

  • 5-minute rolling window is the default: animatedline('MaximumNumPoints', 300) at 1 s update. Buffer formula = WindowSeconds / UpdatePeriodSeconds.

  • Threshold lines drawn once at construction with yline (amber dashed for warn, red dashed for alarm, blue dotted for setpoint).

Writes
  • Every writeValue() confirms. uiconfirm showing node + old + new + units; default option is Cancel, not Apply.

  • Visual feedback after write is a desaturated flash that reverts — [0.7 1 0.7] on success, [1 0.7 0.7] on failure, back to the normal face after ~0.4 s (and revert the value too on failure). Do not flash saturated green [0 0.6 0] or alarm red [0.85 0 0]: those are reserved for operator-verified-OK and active alarms, and a control that stays recolored reads as status rather than as an acknowledgement. Operator must never be uncertain whether the write took effect.

  • Range label adjacent to every writable field. Limits on the spinner enforces the bound; the uilabel makes it visible.

  • E-Stop and other safety-critical actuation confirm both engage and release — accidental touchscreen clicks are the threat. See references/write-safeguards-reference.md.

  • Disable write controls when the system can't accept them (disconnected, E-Stop active) rather than letting writes fail mid-call.

Protocol Selection
  • OPC UA: subscribe() is the default at any node count and update rate, including 1 Hz with a small node set. Reserve timer for non-node UI work (clock displays, alarm flashing, idle dimming).

  • R2026a OPC UA contract: subscribe(client, nodes, cb, PublishInterval=<s>) — always set PublishInterval to the required rate (seconds; default 1) — invokes cb(sub, notification); data lives at notification.Data.Value / notification.Data.Timestamp; the originating node is notification.Node (struct with Name, Identifier, NamespaceIndex). Don't reference src.Name — that worked in older releases when src was the node, but errors in R2026a.

  • Two callback shapes — match the source. When you write the subscribe call, use the 2-arg cb(sub, notification) above. When you paste an OPC UA Explorer–generated dataChangeCallback, it is 3-arg vectorized — cb(subObj, notification, ~) — and notification is a struct array (one entry per node changed this cycle): iterate notification(i).Node.Name / notification(i).Data.Value; never treat it as scalar. Writing a 2-arg @(src, evt) for an Explorer script throws "Too many input arguments"; treating notification as scalar reads only the first node. See references/protocol-cheatsheet.md §OPC UA and references/common-mistakes.md #17.

  • Modbus has no subscribe() — use a polling timer with BusyMode='drop', ExecutionMode='fixedSpacing'. The 6-arg write() order is write(m, target, addr, val, serverId, 'precision') — serverId BEFORE 'precision'. Putting 'precision' in slot 5 (or dropping serverId on a non-default unit) throws "Expected input number 5, serverId, to be one of these types: double, single, …" — the single most common Modbus HMI bug.

  • MQTT subscribe(c, topic, Callback=@cb) — the callback is a name-value pair, not positional. Decode message (a string) per the broker's payload schema (str2double, jsondecode).

  • OSI / AVEVA PI Data Archive: use piclient (Industrial Communication Toolbox, R2022a+). Never call NET.addAssembly('OSIsoft.AFSDK') or write OSIsoft.AF.PI.PIServers / OSIsoft.AF.PISystems code in MATLAB — the toolbox client wraps the AFSDK and returns native MATLAB types (timetable, table, datetime); rolling your own .NET interop loses type coercion, throws on permission errors that the toolbox handles, runs slowly because batched reads are bypassed, and breaks the integrated browser app. PI has no subscribe() — drive trends with a polling timer (same shape as Modbus). Writes via write(piClient, tag, value, TimeInstance=datetime("now")) are R2024a+.

  • PI Asset Framework: use afclient (Industrial Communication Toolbox, R2026a+). Browse with getRootElements / getChildren / findElementByName / findElementByPath / findElementByTemplate; read with Attribute.read (current → table) and Attribute.readHistory (archive → timetable). The AF hierarchy maps directly onto Level 1 → Level 2 → Level 3 drill-down screens. AF current-value writes are not exposed by the toolbox — there is no Attribute.write; route writes through the underlying Attribute.PITag and the PI client.

  • Identify writable attributes from Attribute.WriteAccess and HasTimeSeriesData, the same way OPC UA uses AccessLevelCurrent. Don't infer writability from the name.

  • Use the server's actual tag / attribute names from tags(c).Name or attribute.Name / attribute.Path. Never invent labels — the HMI must mirror the source script's identifiers so operators can cross-reference with the OPC UA Explorer / OSI PI viewer / AF browser.

See references/protocol-cheatsheet.md for OPC UA / Modbus / MQTT API shapes and references/pi-af-cheatsheet.md for PI Data Archive and PI AF; see references/common-mistakes.md for the silent-failure modes.

App Designer Language
  • Initialize typed struct properties: Foo struct = struct(). A bare Foo struct declaration becomes 0×0 struct and app.Foo.Bar = 1 errors with "A dot name structure assignment is illegal when the structure is empty."

  • classdef first. A function block before classdef in the same file is a parse error. Drop the wrapper; the file's first non-comment statement must be classdef, name matching filename.

  • No function … end in inline evaluation. Function definitions are legal only inside code files, never in a command-window / inline context. When you sanity-check helper logic (severity bands, alarm math) through evaluate_matlab_code, the transcript is a command-window context — a pasted function … end block errors with "Function definitions are not supported in this context." Verify by exercising the helper from its file, or inline the arithmetic directly.

  • Subscription cleanup: opc.ua.Subscription has no public delete. Drop the handle (app.Subscription = opc.ua.Subscription.empty;) and disconnect(client) — that releases all subscriptions on that client.

  • Subscribe per widget, never rebuild a single global subscription on each add — each subscribe() accumulates a channel listener; rebuilds leak.

  • Teardown body lives in delete(app); CloseRequestFcn only routes to it. Stop and delete timers, drop subscription handles and disconnect clients inside delete(app), then wire app.UIFigure.CloseRequestFcn = @(~,~) delete(app);. Putting the body on CloseRequestFcn instead leaks everything whenever the app is closed programmatically — which is how every test, script and eval closes it. A leaked timer keeps firing after the figure closes with stale handles. See references/common-mistakes.md #32.

See references/app-designer-gotchas.md for full reproductions, the release-aware AccessLevelCurrent check, and per-widget subscription patterns.

Spec, Review, and Output
  • Write the spec, get it approved, then build. Fill assets/hmi-spec-template.md into <AppName>-hmi-spec.md beside the app, present a ≤10-line summary, and STOP for review. Build only after the user approves. Corrections go into the spec, never into a built app. That file is the hand-off contract.

  • Build by invoking the matlab-build-app skill under the exact name it appears as in your available-skills list — plugin-scoped (<plugin>:matlab-build-app) or bare; only the listed form resolves, so never type a guessed slash command. Hand it the approved spec, which pre-answers its discovery (UIFigure — an HMI is never UIHTML — plus .mlapp, archetype, users, timers). How it builds and what format it emits is its concern.

  • Availability is that same list lookup, never a filesystem search. Not listed → emit the programmatic .m app yourself from the same approved spec; say it runs identically but won't open in App Designer, and that enabling the matlab-app-building plugin and rebuilding from the same spec produces a .mlapp. Never scan the disk for its skill folder — it ships in a different plugin (matlab-app-building), so a recursive search above this skill runs for tens of minutes and still misses.

  • Editing an existing HMI is a delta, not a rebuild — patch the spec, confirm only changed rows, hand off the change.

  • references/app-designer-handoff.md has the division of labor, the hand-off protocol, and the component/behavior recipes the spec is filled from.

Workflow

When wrapping a monitoring script into an HMI, work through these steps in order. Widget choice depends on data type, layout depends on widget choice, alarms/trends/writes layer on top — and the finished design is written to a spec the user reviews before anything is built.

  1. Classify each node / tag / attribute — binary, enumerated, analog with range, analog without range, writable, alarm thresholds (LL/L/H/HH). This list drives every later step. → references/widget-selection-flowchart.md. If the schema isn't given upfront → references/server-agnostic-discovery.md. If wrapping a generated script (OPC UA/Modbus Explorer or MQTT demo) → references/protocol-cheatsheet.md first; if wrapping a PI / PI AF script → references/pi-af-cheatsheet.md, for the exact API shapes.

  2. Pick widgets via the decision flowchart, cross-checking quantity caps. → references/widget-selection-flowchart.md.

  3. Lay out by hierarchy — Level 1 plant overview, Level 2 area, Level 3 detail; row 1 reserved for the persistent alarm banner. → references/color-and-layout-rules.md.

  4. Wire live data per protocol — OPC UA subscribe() (self-written callback = 2-arg cb(sub, notification); an Explorer-generated dataChangeCallback = 3-arg vectorized over a struct array), Modbus polling timer, MQTT subscribe(... Callback=...), PI / PI AF polling timer against read() / Attribute.read. → references/protocol-cheatsheet.md and references/pi-af-cheatsheet.md.

    • Paint the screen before you connect. A client constructed against an unreachable host blocks ~21 s then throws, and no Timeout shortens it. Build the gray-field layout, drawnow, show "Connecting to <host>…" in the banner, and only then construct the client inside a try. Measurements: references/protocol-cheatsheet.md §Polling pattern.

  5. Configure alarms at source AND in a banner, latched only alongside an Acknowledge control. → references/alarm-patterns.md.

  6. Configure trends — 5-minute window, fixed YLim, threshold lines. → references/trend-config-reference.md.

  7. Add write safeguards on every setpoint — confirm, range label, flash feedback. → references/write-safeguards-reference.md.

  8. Write the HMI spec — fill assets/hmi-spec-template.md into <AppName>-hmi-spec.md beside the app: signals, components with ISA-101 values, behaviors and wiring roles, build parameters. Self-audit section 5 first. → assets/hmi-spec-template.md.

  9. STOP — spec review gate. Present a ≤10-line summary plus the file path, then wait; revise the spec and re-present until the user approves, then stamp Review status: APPROVED <date>. Proceed unprompted only on pre-approval, or when the session cannot take a reply (batch/eval run) — then leave it UNREVIEWED and say so before building — summary first, then build. "Seems busy" is not a bypass.

  10. Build from the approved spec per "Spec, Review, and Output" above. → references/app-designer-handoff.md.

  11. Verify by reading the built app back, then close it cleanly. Copy assets/designReportReadback.m beside the app verbatim, keeping its filename — it is a function; do not edit it, do not rename it, and do not re-author your own readback. Call it via mcp__matlab__evaluate_matlab_code: designReportReadback("<AppClass>", "<AppClass>-hmi-spec.md"). It reads the live figure and source, writes design-report.json, deletes the app and reports leaks. Confirm the file's first key is "reportFormat":"hmi-report/1" (self-check: references/common-mistakes.md #33), then compare it with the spec row by row and fix the app where they disagree. Never hand-write or reshape the numbers. If construction fails on a struct or classdef error → references/app-designer-gotchas.md.

Key Functions and Release Gates

references/key-functions.md lists every widget, client, and read/write call with its toolbox and release, plus the four gates that change the design rather than the call — the floor is R2023b (manifest.yaml), set by OPC UA subscribe(). Check it before writing a call on an unknown install; mcp__matlab__detect_matlab_toolboxes confirms what's present.

Common Mistakes

references/common-mistakes.md is a grep-first lookup table of 33 documented anti-patterns — App Designer gotchas, ISA-101 violations, and protocol-specific silent failures — each a Symptom → Wrong → Right → Why block. Don't load it whole: Grep it for the user's error string, or find the anti-pattern in its index table, and read only the matching ### N. entry.

References

Each file carries executable patterns for its topic — calling conventions, constructor arguments, property names, full callback handlers. Load the relevant one before writing code.

Load when...Reference
Writing the HMI spec for review and hand-offassets/hmi-spec-template.md — fill it; it's a form, not a source of domain rules
Verifying a built HMI against its spec (workflow step 11)assets/designReportReadback.m (copy verbatim; see step 11); key contract in assets/design-report-schema.json
Picking widgets for a list of nodesreferences/widget-selection-flowchart.md
Designing screen structure or choosing colorsreferences/color-and-layout-rules.md
Wiring alarm bands at source or the persistent bannerreferences/alarm-patterns.md
Configuring a trend (window, YLim, threshold lines)references/trend-config-reference.md
Adding a writable setpoint with confirm + flash feedbackreferences/write-safeguards-reference.md
Wrapping an OPC UA Explorer / Modbus Explorer / MQTT script — invoking OPC UA method nodes — or hitting protocol-specific bugsreferences/protocol-cheatsheet.md
Wrapping an OSI / AVEVA PI Data Archive script (piclient, tags, read) or a PI AF script (afclient, getRootElements, getAttributes, Attribute.read/readHistory)references/pi-af-cheatsheet.md
Browsing an unknown server and inferring widgets at runtimereferences/server-agnostic-discovery.md
Hard-wiring a known node-to-widget schemareferences/known-schema-patterns.md
Construction errors (struct props, classdef ordering, listener leaks, AccessLevelCurrent)references/app-designer-gotchas.md
Handing the approved spec to matlab-build-app, picking components/behaviors to fill it, or editing an existing appreferences/app-designer-handoff.md
Debugging a specific error, silent failure, or anti-patternGrep references/common-mistakes.md for the error text or anti-pattern (it has an index table) — read only the matching entry, not the whole file
Checking whether a function exists in the user's release, or which toolbox ships itreferences/key-functions.md — manifest.yaml carries the product requirement

Copyright 2026 The MathWorks, Inc.


Files

references/protocol-cheatsheet.md

Protocol Cheatsheet — OPC UA, Modbus, MQTT

Use this when wrapping a script generated by OPC UA Explorer, Modbus Explorer, or any MQTT publish/subscribe demo into an HMI. Each protocol has its own read/write API shape, and the most common HMI bugs come from copying the API surface wrong — not from the HMI conventions themselves.

If you only have time to read one section, read the Don't get this wrong box for the protocol you're wrapping.

Decision rule

User has a script from…Pattern
OPC UA Explorer (opcua(…), findNodeById, subscribe)§OPC UA below; preserve the explorer's node identifiers, do not rename nodes in the HMI
Modbus Explorer (modbus('tcpip',…), read(m,'coils'/'holdingregs',…))§Modbus below; classify each register as coil → lamp, holding → gauge
MQTT publish/subscribe (mqttclient(…), subscribe(c, topic, Callback=…))§MQTT below; one trend line per subscribed topic
OSI / AVEVA PI Data Archive (piclient(…), tags, read) or PI AF (afclient(…), getRootElements, getAttributes)See references/pi-af-cheatsheet.md — separate file because PI has no subscribe() (polling-timer like Modbus) and AF browsing is hierarchical (drill-down). Never replace these with NET.addAssembly('OSIsoft.AFSDK') calls.

OPC UA

Callback shape — pick the right one before writing the subscribe call

Two callback signatures are valid, depending on whether you're writing the subscribe() call yourself or pasting a callback from the OPC UA Explorer app's generated script.

Source of the scriptCallback signaturenotification.Node / notification.Data
You're writing the subscribe call yourselfcb(sub, notification) — 2 argsscalar struct (single change per call)
The subscribe call came from the OPC UA Explorer app's generated scriptcb(subObj, notification, ~) — 3 args (vectorized)struct array (one entry per node that changed in the cycle)

When in doubt, write the subscribe call yourself with the 2-arg shape. Only switch to the 3-arg vectorized shape when you're literally pasting the Explorer-generated dataChangeCallback. Mixing the two — 3-arg signature with 2-arg server output, or vice versa — is the most common "subscription succeeds, callback never fires / errors" cause. Two failure modes flow from getting this wrong (both in common-mistakes.md #17): a 2-arg @(src, evt) for an Explorer script throws "Too many input arguments", and reading the Explorer's notification as a scalar updates only the first node.

In both shapes, notification.Node and notification.Data are struct arrays, not scalars. Even when only one node changes, indexing as notification.Node(i) and notification.Data(i) works for the 1-element case — there's no need to special-case it.

Don't get this wrong
  • Store the subscription handle on a property. app.Subscription = subscribe(...), never sub = subscribe(...). A local variable is GC'd at function exit and the listener detaches silently. See app-designer-gotchas.md.

  • Use the server's actual node names from notification.Node(i).Name. Never invent labels like "Tank-level / Temperature / Pressure" when the server's namespace says Sinusoid, Square, Constant. The HMI must mirror the source script's identifiers so an operator can cross-reference with the OPC UA server.

2-arg callback shape (preferred when you control the subscribe call)

This is the shape to use unless you're pasting an Explorer-generated callback. notification.Node and notification.Data are struct arrays (parallel — one entry per changed node) even when only one node changed:

function onDataChange(app, ~, notification)
    % notification.Node : struct array — one per changed node
    %   fields: Name, Identifier, NamespaceIndex, NodeClass
    % notification.Data : struct array (parallel) — one per changed node
    %   fields: Value, Timestamp, Quality
    nodes = notification.Node;
    data  = notification.Data;
    elapsed = seconds(datetime('now') - app.StartTime);
    for i = 1:numel(nodes)
        name = char(nodes(i).Name);
        val  = data(i).Value;
        w = app.Widgets(strcmp({app.Widgets.Key}, name));
        if isempty(w), continue; end
        w.Display.Value = val;
        if ~isempty(w.Trace)
            addpoints(w.Trace, elapsed, double(val));
        end
        updateAlarmState(app, name, val);
    end
    drawnow limitrate;
end

The for i = 1:numel(nodes) indexing pattern works equally well for the common 1-node-changed case and the multi-node burst case — no special-casing needed.

Wire it with the publishing interval set explicitly to the rate the screen needs (seconds; the default is 1). Stating it records the intended rate in the code and is required for any rate other than 1 s:

app.Subscription = subscribe(app.UAClient, nodes, ...
    @(sub, notification) onDataChange(app, sub, notification), ...
    PublishInterval = 1);                      % ~1 Hz; 0.5 for 2 Hz
Vectorized callback shape (matches Explorer-generated scripts)
function onDataChange(app, ~, notification, ~)
    % notification is itself a struct array — one entry per node that changed.
    nodes = [notification(:).Node];
    data  = [notification(:).Data];
    names = string({nodes(:).Name});
    vals  = [data(:).Value];

    elapsed = seconds(datetime('now') - app.StartTime);
    for i = 1:numel(names)
        w = app.Widgets(strcmp({app.Widgets.Key}, char(names(i))));
        if isempty(w), continue; end
        w.Display.Value = vals(i);
        if ~isempty(w.Trace)
            addpoints(w.Trace, elapsed, double(vals(i)));
        end
        updateAlarmState(app, char(names(i)), vals(i));
    end
    drawnow limitrate;
end
Resolving Explorer-script identifiers to widget keys

The Explorer's findNodeById(uaClient.Namespace, namespaceIndex, identifier) returns a node. Read .Name from each resolved node and use that as the widget key — this preserves the server's naming and survives namespace reshuffles:

namespaceIndex = [3, 3, 3];     % from the generated script
identifier     = [1001, 1006, 1005];
nodes = arrayfun(@(i) findNodeByName_or_findById(uaClient.Namespace, ...
                       namespaceIndex(i), identifier(i)), 1:numel(identifier));
nodeNames = string({nodes.Name});   % e.g. ["Constant", "Square", "Sinusoid"]
% Now build one widget per resolved name. Never relabel.
Writability inferred from the node, not assumed

Use AccessLevelCurrent (release-aware shape — see app-designer-gotchas.md). A node that the Explorer wrote in its sample (% writeValue(uaClient,node,"50")) is the strongest signal that it's writable; promote that node to a setpoint spinner with a confirm dialog (Pattern 5 in SKILL.md).

Method nodes (invoke)

Some OPC UA servers expose method nodes for operator-triggered actions: reset a counter, start a sequence, run a calibration, acknowledge a server-side latch. In an HMI, these become buttons — but treat invocation with the same safeguards as a write: confirm before, flash on success/failure, range/type hints on the inputs.

Discover the method's signature before building the input UI

Don't hard-code the number or types of arguments. Inspect the method node:

methodNode = findNodeById(client.Namespace, 6, 'MyMethod');
numIn   = methodNode.NumInputs;        % e.g. 2
inTypes = methodNode.InputTypes;       % e.g. {'String','Double'}
% Method may also have outputs:
numOut   = methodNode.NumOutputs;
outTypes = methodNode.OutputTypes;

The HMI builds one input field per inTypes{i}, with the type name in the Tooltip and Placeholder so the operator knows what's expected. Dispatch to a numeric vs string field by inspecting the type name.

Construction pattern — typed input row + invoke button
function buildInvokePanel(app, parent, methodNode)
    inTypes = methodNode.InputTypes;
    numIn   = methodNode.NumInputs;

    gl = uigridlayout(parent, [1 numIn + 1], ...
        'BackgroundColor', [0.86 0.86 0.86]);

    app.MyMethodNode    = methodNode;
    app.MyMethodFields  = gobjects(1, numIn);

    for i = 1:numIn
        typeName = inTypes{i};
        if any(strcmpi(typeName, {'Double','Single','Int32','UInt32','Int16','UInt16'}))
            f = uieditfield(gl, 'numeric', ...
                'Tooltip',     sprintf('Argument %d (%s)', i, typeName), ...
                'Placeholder', typeName);
        else
            f = uieditfield(gl, 'text', ...
                'Tooltip',     sprintf('Argument %d (%s)', i, typeName), ...
                'Placeholder', typeName);
        end
        app.MyMethodFields(i) = f;
    end

    app.InvokeButton = uibutton(gl, ...
        'Text',          'Invoke MyMethod', ...
        'ButtonPushedFcn', @(~,~) onInvokeMyMethod(app));
end
Invoke with confirm dialog (treat method invocation as a write)
function onInvokeMyMethod(app)
    % Coerce each field per InputTypes — string for 'String', double for numeric.
    inTypes = app.MyMethodNode.InputTypes;
    args = cell(1, numel(inTypes));
    for i = 1:numel(inTypes)
        v = app.MyMethodFields(i).Value;
        switch lower(inTypes{i})
            case 'string'
                args{i} = string(v);
            otherwise
                args{i} = double(v);
        end
    end

    msg = sprintf('Invoke %s with:\n%s', app.MyMethodNode.Name, ...
        strjoin(arrayfun(@(k) sprintf('  arg %d: %s', k, ...
            string(args{k})), 1:numel(args), 'UniformOutput', false), newline));
    sel = uiconfirm(app.UIFigure, msg, 'Confirm Invoke', ...
        'Options',       {'Invoke','Cancel'}, ...
        'DefaultOption', 'Cancel', ...
        'CancelOption',  'Cancel', ...
        'Icon',          'warning');
    if sel ~= "Invoke"
        return
    end

    try
        outputs = invoke(app.UAClient, app.MyMethodNode, args{:});
        flashFeedback(app.InvokeButton, [0.7 1 0.7]);
        if ~isempty(outputs)
            app.InvokeStatusLabel.Text = sprintf('Returned: %s', ...
                strjoin(string(outputs), ', '));
        end
    catch ME
        flashFeedback(app.InvokeButton, [1 0.7 0.7]);
        uialert(app.UIFigure, ME.message, 'Invoke failed');
    end
end

invoke returns method outputs as a cell array (or empty when the method has no outputs). Display them in a status label adjacent to the button if the method's NumOutputs > 0. The flashFeedback helper is the same one used for writeValue — see references/write-safeguards-reference.md.

Modbus

Don't get this wrong
  • The write() argument order is easy to flip. The supported signature is:

    write(m, target, address, values, serverId, 'precision')

    serverId is before 'precision'. Putting precision in slot 5 produces:

    Expected input number 5, serverId, to be one of these types: double, single, uint8, uint16, ...

    This is the most common Modbus HMI write bug — the spinner and confirm dialog are correct but the actual write call fails. Always use the 6-argument form when you have a non-default serverId, or fall back to the 4-argument form write(m, target, address, values) when serverId is 1 (default).

  • read()'s 6-argument form is read(m, target, address, count, serverId, precisions) where count may be a vector when precisions is a cell array — see the explorer-generated read(m, 'holdingregs', 1, [1, 1], serverId, {'uint16', 'uint32'}). Mirror that shape.

  • Modbus has no subscription mechanism; live updates use a polling timer (BusyMode='drop', ExecutionMode='fixedSpacing'). This is the one place the SKILL.md "subscribe by default" rule does not apply, because there's no subscribe() to call.

Coils → lamps, holding registers → gauges
Register typeMATLAB readWidget
Coil (1-bit)read(m, 'coils', addr, count, serverId)uilamp (binary)
Discrete input (1-bit)read(m, 'inputs', addr, count, serverId)uilamp (binary, read-only)
Holding register (16-bit)read(m, 'holdingregs', addr, count, serverId, 'uint16')uigauge if range known, else uieditfield('numeric','Editable',false)
Input register (16-bit)read(m, 'inputregs', addr, count, serverId)uigauge (read-only)

Holding registers can also be written (write(m, 'holdingregs', …)), so the writable ones get a setpoint spinner. Coils can be written too (write(m, 'coils', addr, [0|1], serverId)) — bind those to a uistatebutton + confirm.

Working write callback (correct argument order)
function sendWrite(app)
    addr   = app.WriteAddrSpinner.Value;
    newVal = app.WriteSpinner.Value;
    msg = sprintf(['Write Holding Register:\n\n' ...
        '  address: %d\n  value:   %d\n  range:   0 - 65535'], addr, newVal);
    sel = uiconfirm(app.UIFigure, msg, 'Confirm Write', ...
        'Options', {'Apply','Cancel'}, ...
        'DefaultOption', 'Cancel', 'CancelOption', 'Cancel', 'Icon', 'warning');
    if sel == "Apply"
        try
            % CORRECT: serverId before 'precision'
            write(app.ModbusClient, 'holdingregs', addr, newVal, ...
                  app.ServerId, 'uint16');
            flashFeedback(app.WriteSpinner, [0.7 1 0.7]);
        catch ME
            flashFeedback(app.WriteSpinner, [1 0.7 0.7]);
            uialert(app.UIFigure, ME.message, 'Write Failed');
        end
    end
end
Polling pattern for Modbus live updates

Ordering matters in startupFcn: lay out and drawnow first, construct the client second, start the timer third. modbus('tcpip', host, port) against an unreachable host blocks ~21 s before throwing, and 'Timeout' does not shorten it (it bounds per-transaction response waiting, not the OS-level TCP connect: measured 21.5 s at the default, 21.1 s at Timeout=1, 21.1 s at Timeout=0.5). The same block applies to opcua/connect, mqttclient, piclient and afclient. Connect before the first drawnow and the operator gets a frozen MATLAB with no window for 21 s, then on failure no window at all:

% startupFcn, after createComponents has laid out the gray-field screen:
app.CommsLamp.Color   = [0.5 0.5 0.5];          % gray = not yet connected
app.AlarmBanner.Text  = "Connecting to " + host + "…";
drawnow                                          % screen is up BEFORE the blocking call

try
    app.Client = modbus('tcpip', host, port);
    app.CommsLamp.Color  = [0.4 0.6 1.0];       % blue = comms up, never green
    app.AlarmBanner.Text = "SYSTEM NORMAL";
catch ME
    app.AlarmBanner.Text = "No connection: " + ME.message;
    return                                       % leave the screen up, writes disabled
end
% Then, with a live client:
app.PollTimer = timer( ...
    'ExecutionMode', 'fixedSpacing', ...
    'BusyMode',      'drop', ...
    'Period',        1, ...
    'TimerFcn',      @(~,~) pollModbus(app));
start(app.PollTimer);

The BusyMode='drop' is mandatory — without it, slow Modbus responses queue up timer callbacks and the UI stalls.

MQTT

Don't get this wrong
  • subscribe(c, topic, Callback=@cb) — the callback is a name-value pair, not a positional argument. Forgetting Callback= registers the subscription but the message stays buffered (read(c) works, but live updates never fire).

  • The callback signature is cb(topic, message) where message is a string. Numeric payloads must be parsed (str2double, jsondecode, etc.). The MQTT layer has no schema — it's bytes on the wire.

  • Subscribe before any messages are expected. If you subscribe after the broker has already sent retained messages, you'll get the latest retained value (good), but missed in-flight messages are gone (no replay).

  • Publish via write(c, topic, message) — message must be a string or char. Numeric setpoints go through string(value) or jsonencode(struct(…)).

Live trend wired to a subscription callback
% Construction (in createComponents) — one trace per subscribed topic
ax = uiaxes(parent, 'XLim', [0 300], 'YLim', [0 100]);
xlabel(ax, 'Time (s)'); ylabel(ax, 'Value'); grid(ax, 'on');
trace = animatedline(ax, 'MaximumNumPoints', 300, ...
    'Color', [0 0 0], 'LineWidth', 1.2);
app.Topics(end+1) = struct('Name', topic, 'Trace', trace, ...
                           'Display', readout, 'StartTime', datetime('now'));

% Subscribe — Callback is a name-value pair
subscribe(app.MqttClient, topic, ...
    Callback = @(t, m) onMessage(app, t, m), ...
    QualityOfService = 1);
function onMessage(app, topic, message)
    idx = find(strcmp({app.Topics.Name}, topic), 1);
    if isempty(idx), return; end
    val = str2double(message);             % adjust per payload schema
    if isnan(val), return; end             % non-numeric payload — skip trend
    elapsed = seconds(datetime('now') - app.Topics(idx).StartTime);
    addpoints(app.Topics(idx).Trace, elapsed, val);
    app.Topics(idx).Display.Value = val;
    drawnow limitrate;
end
Why the live chart is the most common MQTT bug

A common observation in MQTT HMI evaluations: the dashboard layout is correct, the connect/subscribe/publish buttons all work, but the trend stays flat on the first attempt. The two failure modes for "subscribed but no trend" are:

  1. The callback was registered as a positional argument (subscribe(c, topic, @cb) instead of Callback=@cb). The subscription succeeds but the callback is never invoked.

  2. The callback fires but str2double returns NaN (because the broker is sending JSON or comma-separated values), the NaN gets passed to addpoints, and the trace renders nothing without erroring.

Both are silent. Sanity-check by logging topic and message to the command window before plotting.

JSON payloads

When the broker publishes JSON like {"temp": 72.5, "ts": "2026-06-08T10:15:00Z"}:

function onMessage(app, topic, message)
    try
        s = jsondecode(message);
    catch
        return    % malformed JSON
    end
    if ~isfield(s, 'temp'), return; end
    elapsed = seconds(datetime('now') - app.StartTime);
    addpoints(app.TempTrace, elapsed, s.temp);
    drawnow limitrate;
end

Don't infer schema from the topic name; always check isfield before reaching into s.

Cross-references

  • Pattern 6 in SKILL.md — OPC UA subscribe() over timer. The Modbus exception (no subscription, must poll) is documented above; the same exception applies to PI / PI AF.

  • references/pi-af-cheatsheet.md — OSI / AVEVA PI Data Archive (piclient) and PI AF (afclient). Use these clients, never direct AFSDK .NET calls.

  • references/widget-selection-flowchart.md — once payload type is decoded, widget selection is the same across protocols.

  • references/write-safeguards-reference.md — the confirm/range/feedback pattern is identical for writeValue() (OPC UA), write(m, …) (Modbus), write(c, …) (MQTT), and write(piClient, tag, val, TimeInstance=…) (PI). Only the API call inside the try block differs.

  • references/app-designer-gotchas.md — Subscription cleanup, listener-leak gotcha, struct-property defaults.


Copyright 2026 The MathWorks, Inc.


Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an MCP server, Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
MathWorks
Publisher
MathWorks (Official)
Source
matlab/matlab-agentic-toolkit · skills-catalog/test-and-measurement/matlab-build-industrial-hmi · commit 2a7f215
Licence
BSD 2-Clause License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE.md)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by MathWorks.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download matlab-build-industrial-hmi.zip