11 KiB
Driver-less Equipment Namespace — Design (2026-06-08)
Problem
In the OtOpcUa config model, Equipment.DriverInstanceId is a non-null logical FK — every
equipment row must reference a DriverInstance. But the Northwind company overlay's equipment
signals are VirtualTags (driverless — they link via EquipmentId + a Script, with no driver
binding); their live values come from the galaxy mirror over the GalaxyMxGateway driver via the
script return ctx.GetTag("TestMachine_NNN.Sig").Value;.
To satisfy the non-null FK and the validator rule that forbids a GalaxyMxGateway driver in an
Equipment-kind namespace (DraftValidator.ValidateDriverNamespaceCompatibility:
NamespaceKind.Equipment => DriverType != "GalaxyMxGateway"), the loader was cornered into inventing
a placeholder Modbus driver (nw-uns-modbus, 0 tags bound) and pointing all 40 equipment rows
at it.
That placeholder is misleading ("equipment tied to Modbus") and not inert — on every deploy the runtime tries to instantiate a real Modbus driver for it and fails:
WARNING DriverHost: factory for Modbus threw on nw-uns-modbus; stubbing
Cause: Modbus driver config for 'nw-uns-modbus' missing required Host
WARNING DriverHost: no factory for driver type Modbus (instance nw-uns-modbus); falling back to stub
INFO DriverHost: spawned Modbus driver nw-uns-modbus (stub=True)
The data path is correct (the MxGateway is the source, via the VirtualTag indirection — verify-equipment
shows 396 Good), but the driver association is a lie and generates per-deploy exception/stub noise.
Decision
Option B — make equipment driver-less. VirtualTag-only equipment genuinely has no field driver, so
it should reference none: make Equipment.DriverInstanceId nullable, delete the placeholder
driver, and teach the one cluster-attribution site to tolerate a null driver. This keeps the
VirtualTag route intact (so overlay-building, hierarchy reorganization, and value transforms all stay
available — those are the route's strength), removes the misleading Modbus FK, and eliminates the
stub/exception noise.
Chosen over: A (a no-op "Virtual" driver type — keeps a placeholder), and Option 4 (relax the
validator so a GalaxyMxGateway driver can serve equipment directly as galaxy tags — rejected because a
galaxy tag's subscribe ref is FolderPath.Name, i.e. tree placement is coupled to galaxy source, so a
direct-tag overlay could not freely reorganize the company hierarchy without further driver
re-architecture, and it would also drop transforms/ScriptedAlarms and blur the SystemPlatform/Equipment
layering).
Impact map (investigated)
The change is small because almost nothing depends on Equipment.DriverInstanceId.
Schema / entity — the core change
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs:23—public required string DriverInstanceId→public string? DriverInstanceId.- EF infers nullability from the C# type:
OtOpcUaConfigDbContext.ConfigureEquipmenthas no.IsRequired()call, so only the property type changes. One EF migration emits a singleAlterColumn(DriverInstanceId, nullable: true); the model snapshot loses its.IsRequired(). - No FK to drop. Equipment→DriverInstance is a logical FK only — no EF
HasOne/WithMany, no DBFK_…constraint, noON DELETE. Deleting the placeholder driver while equipment still references it is already schema-legal. IX_Equipment_Driveris a plain non-unique, non-filtered index — valid on a nullable column, kept as-is (no filtered-index change; YAGNI).
Validator / composer / applier / runtime — no change required
DraftValidator(all 9 rules read): none dereferenceEquipment.DriverInstanceId; none require an Equipment namespace to have a driver.ValidateDriverNamespaceCompatibilityiteratesdraft.DriverInstances— with the placeholder gone, that namespace simply has no driver row to check.DraftSnapshotFactory.FromConfigDbAsyncloads the fullEquipmententity → naturally carriesnull. No projection change.Phase7Composer—EquipmentNodeusesEquipmentId/Name/UnsLineId; equipment Tags useTag.DriverInstanceId(a separate field), not Equipment's;EquipmentVirtualTagusesEquipmentId/Name/ScriptId.Equipment.DriverInstanceIdis never read.Phase7Applier—MaterialiseHierarchy/MaterialiseEquipmentVirtualTagsbuild folders + variable nodes from projected fields only; never reads the driver.DriverHostActor— spawns one driver actor per DriverInstance in the artifact. Deleting the placeholder DriverInstance ⇒ no Modbus spec ⇒ no stub spawn, nomissing required Hostexception. The VirtualTag host is spawned unconditionally and streams regardless of driver children.VirtualTagHostActor— driver-agnostic; depends only on the dependency mux publishing the galaxy-mirror values (from the GalaxyMxGateway driver, untouched).
The one real code change — DeploymentArtifact.BuildClusterSets
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs (~lines 271–297). Equipment carries
no ClusterId; today it is cluster-attributed via its driver (equipmentIds.Add(id) when
di is not null && driverIds.Contains(di)). With a null driver, equipment — and its VirtualTags — would
be silently dropped in multi-cluster (ScopeTo) mode.
Fix using the UNS hierarchy, which already carries cluster identity (UnsArea.ClusterId exists, and
Equipment → UnsLine.UnsAreaId → UnsArea):
- Build a
UnsLineId → UnsAreaIdmap from the artifact'sUnsLinesarray. - Driver-bound equipment: unchanged (in-cluster when its driver is in-cluster).
- Driver-less equipment: in-cluster iff its line's area is in-cluster
(
areaIds.Contains(lineToArea[UnsLineId])).
This is more correct than the driver path: it anchors on the equipment's actual UNS placement, which is
exactly the same-cluster invariant decision #122 already enforces (driver-cluster must equal
line-cluster). The existing cross-cluster consistency warning (lines 237–244) is phrased "in cluster by
its driver"; for driver-less equipment the attribution is by line, so it is consistent by construction
and the warning won't fire. Single-cluster (ClusterFilterMode.None, the docker-dev topology) never
calls BuildClusterSets, so this is a correctness fix for multi-cluster, not a dev-path requirement.
Loader — scadaproj/otopcua-uns-loader/otopcua_uns.py
In cmd_populate_equipment:
- Remove the placeholder
DriverInstanceINSERT ('Northwind UNS placeholder', 'Modbus', …) and its comment. - Equipment INSERT: drop
DriverInstanceIdfrom the column list (→ NULL). - Retire the
EQ_DRIVER = "nw-uns-modbus"constant and the now-deadDELETE … Tag WHERE DriverInstanceId/DELETE … DriverInstanceteardown lines (in bothcmd_populate_equipmentandcmd_clean) — they become no-ops; remove for clarity. Keep theNamespaceINSERT/teardown. - Fix the stale
# … "an Equipment namespace has a driver" expectationscomment.
AdminUI — two production derefs (found at build time, not by the grep sweep)
Making the column nullable surfaced two .razor sites the impact grep missed (caught by TreatWarningsAsErrors):
Components/Pages/Clusters/TagEdit.razor:191—db.Equipment.Where(e => driverIds.Contains(e.DriverInstanceId))(CS8604). Behavior-preserving fix: guarde.DriverInstanceId != null && …(SQL already excludes NULL from anINset, so this only satisfies the compiler).Components/Pages/Clusters/EquipmentEdit.razor— the equipment editor loadsDriverInstanceIdinto a non-nullFormModel(line 183, CS8601) and mandates a driver on save ("Pick a driver instance."). Decision: give it full driver-less support —FormModel.DriverInstanceId→string?, add a "(none / driver-less)" option to the driver dropdown, relax the mandatory-driver validation, and persist NULL when none is selected (normalize empty → null).
Noted, not changing (YAGNI)
sp_ComputeGenerationDiffincludesDriverInstanceIdin aCHECKSUM(...). It is NULL-tolerant for this one-time transition and sits on the dormant generation-diff path (the active deploy gate is the C#DraftValidator+ConfigComposer.SnapshotAndFlattenAsync, not the SP). Flagged verify-only; if it is ever reactivated, wrap withISNULL(DriverInstanceId, '')as a follow-on.
Testing & verification
- Unit (Configuration tests): a
DraftValidatortest that a draft with driver-less equipment (Equipment.DriverInstanceId == null, Equipment namespace with zero drivers) validates clean. - Unit (Runtime tests): a
BuildClusterSets/ scoped-ParseCompositiontest proving a null-driver equipment is attributed to the cluster of its line's area inScopeTomode (and its VirtualTags are kept), while a wrong-cluster line excludes it. - Existing
Configuration,OpcUaServer(Phase7), andRuntime(DeploymentArtifact) suites stay green. - Migration authored + applied to the docker-dev config DB.
- Live docker-dev: re-run the loader (
populate-equipment, now driver-less) → redeploy → confirm 396 Good still flow on:4840(VERIFY-EQUIPMENT: PASS) and central-1 logs no longer containspawned Modbus driver nw-uns-modbusormissing required Host.
Sequencing & risk
| Step | Risk | Notes |
|---|---|---|
| Entity nullable + EF migration | medium — schema change | single AlterColumn; no FK/constraint to drop; reversible |
BuildClusterSets null-driver attribution |
low–medium — multi-cluster scoping | additive branch; single-cluster path unaffected; covered by a new test |
| Loader edits | low | drop placeholder + NULL the FK; teardown becomes no-ops |
| Live redeploy on docker-dev | low | recreate admin nodes only; sites untouched; the proof the wart is gone |
Branches
- OtOpcUa:
feat/driverless-equipment-namespace(offmaster446a456). - Loader:
scadaproj(otopcua_uns.py). - Migration authored in the Configuration project; applied to docker-dev. Merge/push only on the user's explicit go (the user manages this repo's integration).
Related context
- Investigation: this design is grounded in a three-front impact sweep (EF/schema, validator/artifact, runtime/loader) — see the per-layer findings folded into the Impact map above.
- Decision #122 (same-cluster invariant: equipment's driver-cluster must equal its UNS-line cluster) — the anchor that makes line-based attribution for driver-less equipment correct.
DraftValidator.ValidateDriverNamespaceCompatibility— the rule that forbids GalaxyMxGateway in Equipment namespaces and forced the original placeholder.