Smart Device Model RFC¶
Status: Superseded by ADR-0001 (Accepted 2026-05-11).
Superseded — the Asset CRD was removed
ADR-0001 supersedes the
parallel-physical-layer aspect of this RFC: the Asset CRD and the
AssetMethodCall Phase body type proposed below were removed in
#348.
Smart devices are now modeled as Units with spec.serviceBinding
(#345); remote service invocation is the CALL_SERVICE ST builtin
(#346); discovery is the OPC UA wizard (#347). The transport-binding
ideas from this RFC survive at the driver layer and in
Unit.spec.serviceBinding. See the
Asset dissolution migration guide
for what replaced each removed surface. This document is retained as
a historical design record only.
Issues: #293 (data model) and #294 (UI), addressed jointly.
Scope: A new physical.dcs.io/v1alpha1 Asset CRD that models smart devices (OPC UA PLCs, OPC UA smart sensors, and — in followups — MQTT/Sparkplug B and HTTPS-API devices) as typed properties under the existing ISA-88 physical hierarchy, with one or more transport bindings.
Motivation¶
Today, every external device connects through IOModule with protocol: opcua | modbus | ethernetip | simulation and a []ChannelSpec whose only signal types are digital and analog. That model fits remote-I/O blocks (Wago 750, Phoenix Contact) — bits and registers behind a fieldbus map. It is the wrong shape for everything else:
- An OPC UA PLC exposes hundreds of typed nodes, methods, and structures, not 16 DI/DO bits.
- A PA-DIM-conformant pump exposes a typed object with motor, valve, and diagnostics sub-objects.
- An MQTT/Sparkplug device publishes JSON payloads through topic hierarchies, not channel addresses.
- An HTTPS device exposes a REST surface described by an OpenAPI spec.
These are not minor variations; they are different information models with different addressing schemes and type systems. Bolting them all onto IOModule means either (a) collapsing semantics into opaque strings (today's ns=2;s=Granulator1.AI0 in Address), or (b) growing IOModule into a swiss-army CRD that no longer means anything specific.
The industry trend independently argues for the same separation. The pyramid → hourglass thesis (see Future of Industrial Automation, Dec 2025) puts value on smart devices and edge intelligence at the bottom of the stack, with a Unified Namespace as the canonical organising principle. Every UNS implementation worth the name (HighByte, Cogent DataHub, Litmus) treats devices as modeled assets in an ISA-95 hierarchy, not as flat channel lists. We do not need to ship a UNS today, but the abstraction we pick must not foreclose one.
This RFC introduces an Asset CRD that captures the smart-device shape, leaves IOModule doing its existing fieldbus job unchanged, and lays a single foundation stone — a transport-binding abstraction — that makes a second transport (Sparkplug B, HTTPS) and a UNS publish surface incremental rather than re-modeling.
Relationship to existing types¶
| Existing type | Status after this RFC |
|---|---|
IOModule |
Unchanged. Continues to represent fieldbus remote-I/O modules with digital/analog channels. Existing CRs keep working. |
Unit, ProcessCell, Area, Site, Enterprise |
Unchanged. Asset attaches under Unit. |
Channel, ChannelSpec, ChannelStatus |
Unchanged. Used only by IOModule. Asset introduces its own AssetProperty type to avoid forcing fieldbus semantics on smart devices. |
pkg/driver |
Driver interface stays as is for IOModule use. The Asset controller will reuse the OPC UA driver under a new transport-binding adapter; non-OPC-UA transports get their own packages later. |
Hybrid: an Asset may declare a transport: ioModule binding that wraps an existing IOModule, lifting fieldbus channels into the same Asset/Property shape used by smart devices. This is optional and exists for the eventual UI unification — it is not required for the OPC UA path and does not migrate any existing CR.
Part 1 — Data model (#293)¶
Asset CRD shape¶
// AssetSpec defines the desired state of an Asset.
type AssetSpec struct {
// UnitRef is the parent Unit in the ISA-88 physical model.
UnitRef string `json:"unitRef"`
// AssetType is a free-form classifier ("pump", "valve", "sensor",
// "controller", "smart-sensor") used for UI iconography and grouping.
AssetType string `json:"assetType,omitempty"`
// CompanionSpec records the OPC UA companion specification this asset
// conforms to, when known. Format: "<namespace-uri>:<typeName>".
// Examples:
// "http://opcfoundation.org/UA/PA-DIM:PumpType"
// "http://opcfoundation.org/UA/ISA95:EquipmentType"
// When set, future revisions may render type-specific faceplates and
// methods. This RFC only records the value — see followup issue.
CompanionSpec string `json:"companionSpec,omitempty"`
// Bindings declares one or more transport bindings. Properties reference
// bindings by name. An Asset with multiple bindings models a device that
// exposes the same logical model over more than one transport (e.g., an
// OPC UA pump that also publishes Sparkplug birth) — the operator picks
// one as primary at property level via BindingRef.
Bindings []TransportBinding `json:"bindings,omitempty"`
// Properties is the typed property list — the asset's information model.
Properties []AssetProperty `json:"properties,omitempty"`
Description string `json:"description,omitempty"`
}
// AssetStatus reflects the observed state.
type AssetStatus struct {
// Phase: Pending | Connecting | Online | Degraded | Offline | Fault
Phase string `json:"phase,omitempty"`
// ResolvedBindings carries per-binding observed state (server cert
// fingerprint actually presented, browse-path resolutions, etc.).
ResolvedBindings []BindingStatus `json:"resolvedBindings,omitempty"`
// PropertyStatuses reports the current value, quality, and last-update
// timestamp for each property the runtime has observed.
PropertyStatuses []PropertyStatus `json:"propertyStatuses,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
AssetProperty¶
type AssetProperty struct {
// Name is the logical property name within the asset (e.g., "speed",
// "discharge-pressure", "reset"). Unique within the Asset.
Name string `json:"name"`
// DataType is the property's value type. Maps onto OPC UA built-ins
// and JSON for MQTT/HTTPS bindings.
//
// Bool, Int, UInt, Double, String, ByteString, DateTime, Guid,
// Struct, Array, Method
//
// Struct and Array are recursive — see SubProperties / ItemType when
// we implement them. This RFC supports the scalar types and stores
// Struct/Array/Method as opaque (recorded but not unpacked).
DataType PropertyDataType `json:"dataType"`
// Direction is one of: input, output, bidirectional, method.
// - input: runtime reads from device into status
// - output: runtime writes operator/recipe value to device
// - bidirectional: both (read state, write setpoint)
// - method: callable; not read or written as a value
Direction PropertyDirection `json:"direction"`
// EngineeringUnit and EngineeringRange describe analog properties.
// Auto-populated from OPC UA EUInformation/EURange when available;
// operator may override.
EngineeringUnit string `json:"engineeringUnit,omitempty"`
EngineeringRange *EURange `json:"engineeringRange,omitempty"`
// BindingRef names a TransportBinding from the Asset's spec.bindings.
BindingRef string `json:"bindingRef"`
// AddressInBinding is the transport-specific address. Shape:
// opcua: "node:ns=2;s=Pump1.Speed" or "browse:/Objects/Pump1/Speed"
// mqtt-sparkplug: "spBv1.0/Group/.../Device/Speed" (followup)
// https: "/devices/pump1/properties/speed" (followup)
// ioModule: "<channelName>" (resolves into IOModule.Channels)
AddressInBinding string `json:"addressInBinding"`
// ProtocolHints carries per-property transport tuning that doesn't
// fit the generic shape. For OPC UA: samplingInterval, deadbandType,
// deadbandValue, queueSize. Validated by the binding's controller.
ProtocolHints map[string]string `json:"protocolHints,omitempty"`
}
TransportBinding¶
type TransportBinding struct {
Name string `json:"name"` // local handle
Transport TransportKind `json:"transport"` // opcua | mqttSparkplug | https | ioModule
// OPCUA-specific settings; required iff Transport=opcua.
OPCUA *OPCUABindingOptions `json:"opcua,omitempty"`
// Reserved transport options. Presence in the schema does NOT imply
// implementation; both are tracked in followup issues.
MQTTSparkplug *MQTTSparkplugBindingOptions `json:"mqttSparkplug,omitempty"`
HTTPS *HTTPSBindingOptions `json:"https,omitempty"`
IOModule *IOModuleBindingOptions `json:"ioModule,omitempty"`
}
type OPCUABindingOptions struct {
Endpoint string `json:"endpoint"` // opc.tcp://host:port
SecurityPolicy string `json:"securityPolicy"` // None|Basic256Sha256|Aes128_Sha256_RsaOaep|...
SecurityMode string `json:"securityMode"` // None|Sign|SignAndEncrypt
AuthMode string `json:"authMode"` // anonymous|username|certificate
// CredentialsRef points to a Secret. Shape depends on AuthMode:
// username: keys "username", "password"
// certificate: keys "clientCert", "clientKey"
CredentialsRef *corev1.SecretReference `json:"credentialsRef,omitempty"`
// ServerCertSHA256Pin is the SHA-256 fingerprint of the server cert
// the operator approved at first-trust. The runtime rejects connections
// whose presented cert hashes differently. Empty value means
// "anonymous-trust" — the runtime accepts whatever the server presents
// and writes the observed fingerprint into status. Intended for dev
// only; pharma deployments must pin.
ServerCertSHA256Pin string `json:"serverCertSha256Pin,omitempty"`
// SubscriptionDefaults applies when AssetProperty.ProtocolHints does
// not override.
SubscriptionDefaults *OPCUASubscriptionOpts `json:"subscriptionDefaults,omitempty"`
}
Addressing decisions (#293 open questions, resolved)¶
- Node ID encoding. Keep the OPC UA string form, but require an explicit prefix to disambiguate raw node IDs from browse paths:
node:ns=2;s=Pump1.Speedorbrowse:/Objects/Pump1/Speed. The runtime resolves browse paths to node IDs at reconcile time and writes them intostatus.resolvedBindings[].addressResolutions. - Browse paths vs node IDs. Both supported. Recommendation: prefer
browse:form because it survives server restarts that re-number IDs. Document the trade-off in the user-facing docs. - Namespace URIs vs indexes. Both accepted in node-ID form:
ns=2(index) orns=urn:siemens:s7-1500(URI). Indexes are resolved against the server's namespace array at every connect; URIs are stable. Recommendation: prefer URI form. Followup issue tracks UI conversion. - Type system.
PropertyDataTypecovers the OPC UA built-ins. Struct/Array/Method are recorded but not unpacked in this RFC; they appear in browse trees with a "deferred" badge. Followup issue covers Struct decomposition into sub-properties. - Sessions, subscriptions. One shared session per
(endpoint, security)tuple, shared across all Assets bound to that endpoint. Per-property subscription defaults viaOPCUASubscriptionOpts; per-property override viaAssetProperty.ProtocolHints. Defaults: PublishingInterval=1s, SamplingInterval=500ms, QueueSize=10, DiscardOldest=true. Subject to revision after #270 Phase 2. - Methods. Recorded as
DataType: Methodproperties. Runtime + System UI invocation lands in #298 (engineering-grade Actions tab on the Asset detail page; one AuditRecord per call; e-signature in enforced topologies). Recipe-action wiring stays a followup (#332). - Security shape.
securityPolicy,securityMode,authModepromoted to first-class fields. Credentials and certs go in Kubernetes Secrets viaCredentialsRef. Server cert is operator-approved at first-trust and pinned by SHA-256. - HA and A&C. Out of scope. We already have
HistorianandAlarmCRDs that solve these problems differently. Bridging is filed as two followup issues (one each). - Diagnostics. Server status (
ServerState,ServerStatus) exposed viaAsset.status.conditionsrather than as auto-generated properties. Per-property quality propagates from OPC UAStatusCodeintoPropertyStatus.Quality(Good / Bad / Uncertain + sub-code string for diagnosis).
What stays in IOModule¶
Anything where the device is a fieldbus remote-I/O block: Modbus TCP coil/register maps, EthernetIP assembly objects. The IOModule.Channels model fits these cleanly and there is no reason to migrate. Plants run mixed populations (Wago + smart sensors) and will for the next decade; both CRDs coexist.
The OPC UA protocol on IOModule becomes legacy as soon as the Asset path is available. We will not delete it in this RFC, but documentation will steer new OPC UA usage to Asset. Migration tooling and an eventual deprecation are filed as a followup.
Why these are the right boundaries¶
- Asset is the smart-device-shaped abstraction. Typed properties, transport bindings, semantic placement in the ISA-88/95 hierarchy. This is the shape every modern UNS / IIoT platform converges on.
- Transport binding is the extensibility seam. Adding Sparkplug B becomes "implement
mqttSparkplugbinding options + a driver for the Asset controller" — no Asset re-modeling, no UI re-modeling, no recipe-layer impact. - The ISA-88 physical hierarchy IS the namespace.
Enterprise → Site → Area → ProcessCell → Unit → Asset → Propertyis already a UNS topic structure. A future UNS publish surface is a small bridge, not a re-platforming.
Part 2 — UI design (#294)¶
Where browse lives¶
A standalone OPC UA Explorer view in the System UI, plus an inline tree picker in the Asset wizard. Both consume the same gateway endpoint:
POST /api/v1/asset-discovery/opcua/browse— body:{endpoint, security, credentialsRef, fromNode}→ returns one level of the address space (children with browse name, node ID, type definition, EURange-if-present, value-if-readable).
The Explorer view is also useful for ad-hoc reads/writes during commissioning. It does not create Assets directly — operators always go through the Asset wizard so the change has a ChangeRequest audit trail in enforced topologies (see GitOps Enforcement RFC).
Caching policy: browse results are not cached on the server. The UI may cache per-tab for the operator's session (refresh button always visible). Re-browse on every Asset edit is the safe default.
Endpoint discovery and cert pinning¶
A required step in the Asset wizard — one screen, in this order:
- Operator types
opc.tcp://host:port. - Gateway calls
GetEndpointsand returns the advertised endpoints (security policies × modes) plus each endpoint's server-cert SHA-256 fingerprint. - Operator picks an endpoint. The UI shows the server cert fingerprint prominently and asks for explicit "I trust this server" confirmation. This is the operator-side cyber gate the Future of Industrial Automation report calls out as board-level concern for pharma.
- The fingerprint is recorded in
OPCUABindingOptions.ServerCertSHA256Pinon save. Subsequent connects validate. - A separate "Trust list" page (followup) shows all pinned fingerprints across Assets so an operator can rotate / re-approve after a cert renewal.
For pharma deployments, the wizard rejects securityPolicy: None + securityMode: None with a non-bypassable warning. Dev/sandbox topologies may relax via Helm value (gateway.opcua.allowInsecure: true).
Security configuration UI¶
In the Asset wizard's "Security" pane:
- Auth mode as a three-tab control: Anonymous | Username/Password | Certificate.
- Username/password — reads/writes a Kubernetes Secret behind the scenes; the UI never shows the password back.
- Certificate — operator chooses "Generate client cert" (server-side, stored in Secret, fingerprint shown to be exchanged out-of-band with the OPC UA server admin) or "Bring your own" (paste PEM). Auto-gen flow is filed as a followup; this RFC ships BYO only.
- Trust-list inbox — when a connection attempt presents an unpinned cert, the wizard shows the fingerprint and offers approve/reject. Followup issue covers the standalone inbox UX for cert rotation.
Channel/property parity with Modbus¶
The Asset wizard's "Properties" step is where the model diverges from the IOModule editor.
Modbus IOModule: operator types address strings into a flat list.
OPC UA Asset: operator either (a) browses to a parent node (e.g., /Objects/Pump1) and the wizard auto-suggests one property per child leaf, with name = browse name, dataType = server-reported type, eng range = EURange if present; or (b) browses to a typed object (e.g., a PA-DIM PumpType) and the wizard recognises the companion-spec type and offers a pre-shaped property template. (b) is filed as a followup — this RFC ships (a).
For data types beyond digital/analog:
- String, ByteString, DateTime, Guid render as read-only properties in the faceplate (Strings get a value strip; the others get a hex/timestamp display).
- Struct, Array — appear in the property list with a "complex type — subscribed but not displayed" badge. Faceplate decomposition is filed as a followup.
Quality codes¶
The faceplate already shows Good | Uncertain | Bad. OPC UA's sub-codes (e.g., UncertainSubNormal, BadOutOfService) are propagated into a tooltip on the quality indicator and into the historian for diagnostic filtering. No new colors — green/red are reserved for healthy/unhealthy per existing convention; quality uses neutral chip styling with the sub-code as label.
Methods¶
In the address-space tree, Method nodes appear with a () suffix and a "method (deferred)" badge. They do not appear in the Asset wizard's Properties step in this RFC. Followup issue covers the invocation UI (input form, call button, output display) and the recipe-action wiring.
Operator vs engineer separation¶
- System UI (engineers): Asset wizard, OPC UA Explorer, trust-list inbox.
- HMI (operators): faceplates render Asset properties exactly like Channel-backed faceplates — operators do not see node IDs, browse paths, or security policies. The protocol layer is invisible to them by design.
Diagnostics surface¶
Asset.status.conditions carries connection-level health. The Asset detail page shows:
- Connection state (Online / Connecting / Degraded / Offline / Fault)
- Server endpoint + pinned fingerprint
- Server BuildInfo / CurrentTime if the server exposes the standard nodes
- Per-property subscription status: which were established as subscriptions vs which fall back to poll, with sampling/publishing intervals
State machine¶
stateDiagram-v2
[*] --> Pending
Pending --> Connecting: bindings resolved, attempting connect
Connecting --> Online: session established, subscriptions active
Connecting --> Fault: cert mismatch / auth failure / unreachable
Online --> Degraded: some properties Bad/Uncertain
Degraded --> Online: all properties Good
Online --> Offline: server disconnect
Offline --> Connecting: reconnect attempt
Fault --> Connecting: spec edited
Online --> [*]: deletion
Offline --> [*]: deletion
Fault --> [*]: deletion
Reconnect is exponentially backed off, capped at the pkg/driver/reconnect.go default. Cert-mismatch is a terminal Fault until the operator explicitly re-approves the new fingerprint.
Open questions¶
These are deliberately deferred to the followup issues filed alongside this RFC. The pattern matches GitOps Enforcement RFC — each is revisited in its implementation issue rather than this design doc.
- Sparkplug B transport shape. The schema reserves
MQTTSparkplugBindingOptions, but the actual fields (broker URL, group/edge-node namespace, primary-host pattern) are deferred. Decide in the Sparkplug followup. - Companion-spec first-class import. Recording
CompanionSpecis in this RFC; recognising PA-DIM/ISA-95 types and instantiating typed property templates is a followup. Pharma's PA-DIM coverage will likely come first. - OPC UA Method invocation. Schema slot, runtime, and System UI ship in #298. Recipe-action wiring (operator path) is followup #332.
- A&C bridging. OPC UA Alarms & Conditions → existing
AlarmCRD; deferred. - HA bridging. OPC UA Historical Access → existing
Historian; deferred. - Cert auto-generation + trust-list inbox UX. Wizard ships BYO cert and one-at-a-time fingerprint approval; standalone inbox + auto-gen flow are followups.
- Namespace URI vs index UI surfacing. Both accepted at the schema level; explicit UI for picking URI form and detecting index drift is a followup.
- UNS publish surface. Asset hierarchy → MQTT topic structure for an externally-mounted UNS broker, deferred. Schema does not foreclose; followup proposes a topic template.
- IOModule vs Asset migration guidance. When does an existing OPC UA
IOModulebecome anAsset? Tooling, docs, and eventual deprecation are followups. - Subscription defaults after Phase 2 of #270. PublishingInterval=1s / SamplingInterval=500ms / QueueSize=10 are reasonable starting points but are subject to revision once #270 Phase 2 (real PLC) produces field experience. Each of these defaults is annotated as
// revisable after #270 Phase 2in the type definitions.
Acceptance for #293 and #294¶
- This RFC merged.
api/physical/v1alpha1/asset_types.goskeleton compiles andmake manifests generateproduces a valid CRD. Includes the data-types / direction / transport-kind enums and theOPCUABindingOptionsstruct.- Each open question in the issues' acceptance criteria has either an answer in this doc or an explicit "deferred" pointer to a filed followup issue.
- Wireframes for: address-space browse, endpoint picker with cert fingerprint, Asset wizard's Properties step, Asset detail page diagnostics. (These can land as ASCII sketches in this doc plus follow-up screenshots from the implementation issue — not blocking the RFC merge.)
- Followup issues filed for every deferred item above and cross-linked from #293 and #294.
The reconciler, browse endpoint, wizard implementation, and OPC UA Explorer view are explicitly out of scope and live in their own issues.