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 uses (as will the planned marketplace), 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 — changing it is a manifest edit, with no platform code change.

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 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: StartCmd
dataType: Bool
access: Write
physicalRole: Input
description: >-
Start command port. The command writes this; the block consumes it as a rising edge and
self-clears. Writing the Running STATUS tag instead cannot start or stop
anything — the controller drives it.
- name: StopCmd
dataType: Bool
access: Write
physicalRole: Input
description: >-
Stop command port. The command writes this; the block consumes it as a rising edge and
self-clears. Writing the Running STATUS tag instead cannot start or stop
anything — the controller drives it.
- name: ResetCmd
dataType: Bool
access: Write
physicalRole: Input
description: >-
Fault-reset command port. reset-fault writes this; the block consumes it as a
rising edge and self-clears. Writing the Fault/FaultCode STATUS tags instead
cannot clear anything — the controller drives them.
- 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.

StartCmd, StopCmd and ResetCmd are command ports (access: Write, physicalRole: Input). Commands write command ports, never status tags: the control block consumes the port and drives Running, Fault and FaultCode itself. The shipped pump declares EnableAutoCmd and DisableAutoCmd the same way for its mode commands.

Field-by-field:

FieldAllowed values (this manifest)Notes
dataTypeBool, Int, Real (also DInt, String)Capitalised; mirrors the controller types
accessRead, ReadWrite, WriteThe UI-facing read/write capability; command ports use Write
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, external signals, setpoints and command ports (DischargePressure, FlowRate, MotorCurrent, BearingTemp, Interlock, SpeedSetpoint, StartCmd, StopCmd, ResetCmd); 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 are read from a controller (through an edge, or for a ControlLogix directly by a site server in early access) 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. States are evaluated in order and the first match wins, so list off-normal states (Faulted, Interlocked, Local) before the run states. The platform rejects a type that declares a state no tag combination can reach:

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

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, and WinUI3 is a planned target.

components/faceplate.yaml binds {tags.X} (and {tags.X.range}), {calculations.X}, {state.X}, {instance.X} and alarm limits ({alarms.<id>.limit}):

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}"
range: "{tags.Speed.range}"
unit: rpm
label: Speed
- type: metric
props:
value: "{calculations.HydraulicPower}"
unit: kW
label: Power
precision: 1
- type: slider
props:
value: "{tags.SpeedSetpoint}"
range: "{tags.SpeedSetpoint.range}"
step: 100
unit: rpm
label: Speed Setpoint
disabled: "{!tags.AutoMode}"
- type: button-group
props:
justify: center
children:
- type: button
props:
label: Start
command: start
variant: primary
disabled: "{tags.Running || tags.Fault || tags.Interlock}"
- type: button
props:
label: Stop
command: stop
variant: secondary
disabled: "!{tags.Running}"
- type: button
props:
label: Reset
command: reset-fault
variant: outline
disabled: "!{tags.Fault}"

{tags.Speed.range} reads the rangeMin/rangeMax declared on the tag in Step 2, so the gauge and slider scale from the model rather than from numbers typed into the faceplate.

Bindings can be inverted ({!tags.AutoMode}) and combined with boolean operators ({tags.Running || tags.Fault || tags.Interlock}) directly inside prop expressions. Whether a command button is enabled follows its command’s preconditions (Step 7). On the web, setpoints are shown read-only for now: setpoint entry is not yet available there. 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 a class (operate, mode, reset, maintenance or jog), preconditions (guards with operator-facing messages), tagWrites, and an audit flag. Parameterised commands reference their arguments with {params.X}:

- id: start
name: Start Pump
class: operate
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:
# The command PORT, not the Running status tag (the controller drives that).
- tagName: StartCmd
value: "true"
- id: stop
name: Stop Pump
class: operate
# A stop-class command must declare its direction, or the type is refused at load.
direction: safe
description: Stops the pump
confirm: true
confirmMessage: "Stop {instance.name}?"
audit: true
preconditions:
- condition: "{tags.Running}"
message: Pump is not running
- condition: "!{tags.LocalMode}"
message: Pump is in local mode
tagWrites:
- tagName: StopCmd
value: "true"
- id: reset-fault
name: Reset Fault
class: reset
description: Resets the fault condition
confirm: true
confirmMessage: "Reset the fault on {instance.name}? The cause must be cleared first."
audit: true
preconditions:
- condition: "{tags.Fault}"
message: No fault to reset
- condition: "!{tags.LocalMode}"
message: Pump is in local mode
tagWrites:
- tagName: ResetCmd
value: "true"
- id: set-speed
name: Set Speed
class: operate
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}"

The class decides where a command is offered. On the web, only operate, mode and reset commands are drawn as faceplate controls, and a command with no class never is. A reset-class command must declare confirm: true, or the type is refused at load. A stop-class command (stop, stop-rakes, stop-loadout, close, close-loadout or abort) must declare direction: safe or direction: not-safe, and no other command may declare one. A safe stop takes no parameters, and Consystence never holds it back on its preconditions: the controller’s own logic decides whether it acts, so each of its preconditions must read plant state. Commands that take parameters, such as set-speed, are not yet offered.

Note that start writes only the StartCmd command port, never the Running status tag, and relies on preconditions plus the controller’s start logic for interlocks. A command the faceplate offers must write only command ports and setpoints; a faceplate button bound to a command that writes a status tag is refused at load. Apart from a safe stop’s, 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, check it against this list:

  • Confirm dataType, access and physicalRole casing on every tag.
  • Check that all {tags.X}, {calculations.X} and sceneBindings names resolve to declared entries, and that faceplate ranges and limits bind to the model, never to numeric literals.
  • Check state order: every state must be reachable (the loader rejects one that earlier states hide).
  • Verify that commands write only command ports and setpoints that exist, that every command declares a class, that every stop-class command declares a direction, 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.