Skip to content

Creating a Device Type

This page walks through authoring a device type end to end, using the real consystence.pump.centrifugal package that ships in the Consystence equipment library. A type is a reusable definition — its tags, states, calculations, faceplates, alarms and commands are written once and then bound to many per-site instances (each with its own AssetNumber, e.g. PP-01). For the type-versus-instance distinction, see the type system.

A type is a folder of YAML, keyed by its typeId. The centrifugal pump looks like this:

consystence.pump.centrifugal/
├── manifest.yaml # typeId, tags, states, calculations, sceneBindings, aiContext
├── simulation.yaml # optional deterministic sim profile (never writes Output tags)
├── components/
│ ├── faceplate.yaml # full operator control + monitoring screen
│ └── status-card.yaml # compact overview card
├── alarms/
│ └── alarms.yaml
└── commands/
└── commands.yaml

manifest.yaml is the spine — every other file references the tags, states and calculations it declares. We will build the manifest first, then layer the components, commands and alarms on top.

Open manifest.yaml and declare the identity and metadata. The typeId is the dotted, reverse-domain identifier; it is the stable key the platform and the marketplace use, so it never changes once published.

typeId: consystence.pump.centrifugal
controlModel: peer # control-routing model for this type
version: "1.0.0"
name: Centrifugal Pump
description: >
A general-purpose centrifugal pump with VFD speed control, including
monitoring of discharge pressure, flow rate, and motor parameters.
Supports start/stop control with interlock checking and fault handling.
publisher: Consystence
license: MIT
category: Pumps
platform: ">=1.0.0"

Quote version and platform so YAML reads them as strings, not numbers. controlModel: peer declares how the device participates in control resolution — a property of the type, not a per-instance choice. The field is live on the manifest: peer routes instances to the peer control model, while omitting it (or declaring legacy) keeps the combined routing — a library update alone can change it, with no platform release.

Tags are the data points the type exposes. Casing matters: dataType and access use the platform’s canonical capitalisation. Declare a physicalRole on every tag — it defaults to Input when omitted, but declaring it explicitly keeps the ownership split below legible. Here are real tags from the manifest, covering each shape you will need:

tags:
- name: Running
dataType: Bool
access: Read
physicalRole: Output
description: Pump is running (motor energised). Latched by the vPLC's start logic from StartCommand + interlocks.
- name: Speed
dataType: Real
access: ReadWrite
physicalRole: Output
unit: rpm
rangeMin: 0
rangeMax: 3600
description: Current motor speed. Ramped by the vPLC's control logic toward SpeedSetpoint when Running.
- name: SpeedSetpoint
dataType: Real
access: ReadWrite
physicalRole: Input
unit: rpm
rangeMin: 0
rangeMax: 3600
description: Speed setpoint for VFD. Operator/AI writes; vPLC reads to drive ramp logic.
- name: BearingTemp
dataType: Real
access: Read
physicalRole: Input
unit: degC
description: Bearing temperature. Simulator computes from thermal model; vPLC sees as a sensor reading.
- name: FaultCode
dataType: Int
access: Read
physicalRole: Output
description: Fault code (0=none, 1=overload, 2=bearing, 3=seal, 4=cavitation). Written by vPLC when fault active.

Field-by-field:

FieldAllowed values (this manifest)Notes
dataTypeBool, Int, Real (also DInt, String)Capitalised; mirrors the controller types
accessRead, ReadWrite (also Write)The UI-facing read/write capability
physicalRoleInput, Output (also Internal, System)Recommended on every tag (defaults to Input if omitted) — direction from the controller’s point of view
unitrpm, bar, m3/h, A, degCEngineering unit for display and trending
rangeMin / rangeMaxe.g. 0 / 3600Drives gauge scaling and validation

The key idea is that physicalRole is independent of access. Speed is ReadWrite in the UI but physicalRole: Output because the control logic owns it; SpeedSetpoint is also ReadWrite in the UI but physicalRole: Input because the controller reads it. Input tags are sensor readings and external signals (DischargePressure, FlowRate, MotorCurrent, BearingTemp, Interlock, SpeedSetpoint); output tags are owned by the control logic (Running, Speed, Fault, FaultCode, LocalMode, AutoMode, SetpointRangeFault). This ownership split is what lets a simulation profile or a real controller drive the same type without conflict:

flowchart LR
    Field["Field sensors / simulator<br/>physicalRole: Input"] -->|readings + signals| DI["Device instance<br/>tag cache"]
    vPLC["Control logic / vPLC<br/>physicalRole: Output"] -->|Running, Speed, Fault| DI
    DI -->|"reads via {tags.X}"| UI["Faceplate + scene<br/>UI session"]
    UI -->|"commands -> tagWrites"| DI

For the full data-type and access reference, see Tags. How those tags actually reach a physical device — the edge runtime and the PLCnext gateway — is covered in PLC communication.

States are derived labels evaluated from tag conditions. Each state lists one or more boolean conditions (all must hold) and a color. Order matters — the first matching state wins, so put the most specific or most severe first:

states:
- name: Stopped
conditions:
- "!{tags.Running}"
- "!{tags.Fault}"
color: slate
- name: Running
conditions:
- "{tags.Running}"
- "!{tags.Fault}"
color: emerald
- name: Faulted
conditions:
- "{tags.Fault}"
color: red
- name: Interlocked
conditions:
- "{tags.Interlock}"
- "!{tags.Running}"
color: amber
- name: Local
conditions:
- "{tags.LocalMode}"
color: blue

The resolved state surfaces in the UI as {state.name} and {state.color}, which the faceplate uses for its status banner.

Calculations are read-only derived values defined by an expression over tag bindings. They are addressable from components as {calculations.X}:

calculations:
- name: HydraulicPower
expression: "({tags.FlowRate} * {tags.DischargePressure}) / 36"
unit: kW
description: Calculated hydraulic power output
- name: Efficiency
expression: "(({tags.FlowRate} * {tags.DischargePressure}) / 36) / ({tags.MotorCurrent} * 0.4) * 100"
unit: "%"
description: Estimated pump efficiency

Quote "%" so YAML does not treat it specially.

sceneBindings are per-type aliases consumed by the process-scene resolver. Each binding gives the scene layout a stable name that the resolver joins with a placement prefix to form the full {tags.<prefix><name>} expression — so a P&ID scene can reference “the pump’s running indicator” without hard-coding tag names:

sceneBindings:
- name: Running
tag: Running
description: Pump running indicator for the P&ID body colour
- name: Speed
tag: Speed
description: Pump speed readout
- name: Fault
tag: Fault
description: Fault banner visibility
- name: Current
tag: MotorCurrent
description: Motor current readout
- name: AutoMode
tag: AutoMode
description: Auto/manual mode indicator

Components live under components/ and describe a semantic UI tree — you author the meaning, not the markup. The tree is designed to project to multiple renderers from one definition: the browser and the CLI are peer renderers of the same JSON wire format (the legacy HTML shell is being removed), and WinUI3 is a planned target.

components/faceplate.yaml uses four binding namespaces — {tags.X}, {calculations.X}, {state.X} and {instance.X}:

componentType: faceplate
name: Pump Faceplate
description: Full control and monitoring faceplate for centrifugal pump
layout:
type: card
props:
title: "{instance.name}"
subtitle: "Centrifugal Pump"
statusColor: "{state.color}"
statusText: "{state.name}"
children:
- type: gauge
props:
value: "{tags.Speed}"
min: 0
max: 3600
unit: rpm
label: Speed
- type: metric
props:
value: "{calculations.HydraulicPower}"
unit: kW
label: Power
precision: 1
- type: slider
props:
value: "{tags.SpeedSetpoint}"
min: 0
max: 3600
step: 100
unit: rpm
label: Speed Setpoint
disabled: "{!tags.AutoMode}"
- type: button
props:
label: Start
command: start
variant: primary
disabled: "{tags.Running || tags.Fault || tags.Interlock}"

Bindings can be inverted ({!tags.AutoMode}) and combined with boolean operators ({tags.Running || tags.Fault || tags.Interlock}) directly inside prop expressions — that is how the setpoint slider is disabled outside auto mode and the Start button is disabled while running, faulted or interlocked. The compact status-card.yaml follows the same model with clickAction: open-faceplate to drill into the full faceplate.

Commands live under commands/ and are the only sanctioned way the UI writes tags. Each command declares preconditions (guards with operator-facing messages), tagWrites, and an audit flag. Parameterised commands reference their arguments with {params.X}:

- id: set-speed
name: Set Speed
description: Sets the speed setpoint
confirm: false
audit: true
parameters:
- name: speed
dataType: Real
min: 0
max: 3600
unit: rpm
description: Target speed in RPM
preconditions:
- condition: "!{tags.LocalMode}"
message: Pump is in local mode
tagWrites:
- tagName: SpeedSetpoint
value: "{params.speed}"
- id: start
name: Start Pump
description: Starts the pump at current speed setpoint
confirm: true
confirmMessage: "Start {instance.name}?"
audit: true
preconditions:
- condition: "!{tags.Running}"
message: Pump is already running
- condition: "!{tags.Fault}"
message: Clear fault before starting
- condition: "!{tags.Interlock}"
message: Safety interlock is active
- condition: "!{tags.LocalMode}"
message: Pump is in local mode
tagWrites:
- tagName: Running
value: "true"

Note that start only writes the Running tag and relies on preconditions plus the controller’s start logic for interlocks. Preconditions are server-enforced operability gates, evaluated fail-closed against good-quality tag data (missing or degraded data rejects the command) — but still not a safety function; interlocks remain in the controller.

Alarms follow the same pattern under alarms/ (condition + priority + message); they are covered in detail in Alarms and commands.

simulation.yaml is an optional deterministic profile that lets the type run without hardware. By design it never writes output tags — those stay owned by the control logic; the profile drives input tags (sensor readings and external signals) and, where declared, simulator-only Internal tags:

schemaVersion: 1
deviceType: consystence.pump.centrifugal
deviceTypeVersion: ">=1.0.0"
initialValues:
SpeedSetpoint: 75
MotorCurrent: 0
BearingTemp: 32
FlowRate: 0
DischargePressure: 0
Interlock: false
stateScopedDynamics:
- state: Running
tag: FlowRate
model: firstOrderLag
parameters:
steadyState: "Speed / 3600 * 120"
timeConstant: 2.0

Every tag a profile writes must not be declared physicalRole: Output in the manifest — the validator rejects simulator writes to controller-owned outputs. Profiles write Input tags (and Internal tags, for model-internal state); that alignment with Step 2 is what makes this profile valid.

For the full profile schema — dynamics models, disturbances, failure modes and validation rules — see Simulation profiles.

Once the package is assembled, validate it before publishing:

  • Confirm dataType, access and physicalRole casing on every tag.
  • Check that all {tags.X}, {calculations.X} and sceneBindings names resolve to declared entries.
  • Verify command tagWrites only target tags that exist, and that simulation profiles never write Output tags.

To go deeper:

  • Tags — data types, access levels and ranges in full.
  • Alarms and commands — alarm rules and the command model.
  • Type system — types versus instances, and the engineering model.