9 Commits

Author SHA1 Message Date
Joseph Doherty f5ef0991af docs(adminui): correct equipment help text for driver-less equipment (review I1/M1)
v2-ci / build (push) Failing after 4m24s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-08 07:13:45 -04:00
Joseph Doherty b962b041f3 docs(plan): driver-less equipment — all tasks complete + live-verified 2026-06-08 07:10:14 -04:00
Joseph Doherty d909a8e4f6 docs+test(deploy): clarify driver-less attribution docs + no-line exclusion test (Task 2 review) 2026-06-08 07:02:25 -04:00
Joseph Doherty 0b5fc44866 fix(adminui): show + clarify driver-less equipment across list/import (Task 1 review) 2026-06-08 07:00:03 -04:00
Joseph Doherty 53eb3fcda5 test(config): DraftValidator accepts driver-less equipment + driverless equipment namespace 2026-06-08 06:56:22 -04:00
Joseph Doherty c688899134 fix(deploy): cluster-attribute driver-less equipment via its UNS line area (BuildClusterSets) 2026-06-08 06:53:41 -04:00
Joseph Doherty d2dbf7b0d7 feat(config): make Equipment.DriverInstanceId nullable + driver-less AdminUI support + migration 2026-06-08 06:49:28 -04:00
Joseph Doherty a94d03a194 docs(plan): driver-less equipment namespace implementation plan (#143-147) 2026-06-08 06:40:14 -04:00
Joseph Doherty 064adb0bd0 docs(design): driver-less equipment namespace (nullable Equipment.DriverInstanceId, drop Modbus placeholder) 2026-06-08 06:36:40 -04:00
14 changed files with 2457 additions and 18 deletions
@@ -0,0 +1,160 @@
# 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.ConfigureEquipment` has **no
`.IsRequired()`** call, so only the property type changes. One EF migration emits a single
`AlterColumn(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 DB
`FK_…` constraint, no `ON DELETE`. Deleting the placeholder driver while equipment still references it
is already schema-legal.
- `IX_Equipment_Driver` is 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 dereference `Equipment.DriverInstanceId`; none require an
Equipment namespace to have a driver. `ValidateDriverNamespaceCompatibility` iterates
`draft.DriverInstances` — with the placeholder gone, that namespace simply has no driver row to check.
- **`DraftSnapshotFactory.FromConfigDbAsync`** loads the full `Equipment` entity → naturally carries
`null`. No projection change.
- **`Phase7Composer`** — `EquipmentNode` uses `EquipmentId`/`Name`/`UnsLineId`; equipment *Tags* use
`Tag.DriverInstanceId` (a separate field), not Equipment's; `EquipmentVirtualTag` uses
`EquipmentId`/`Name`/`ScriptId`. `Equipment.DriverInstanceId` is never read.
- **`Phase7Applier`** — `MaterialiseHierarchy` / `MaterialiseEquipmentVirtualTags` build 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, no `missing required Host` exception**.
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 271297). 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 → UnsAreaId` map from the artifact's `UnsLines` array.
- **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 237244) 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 `DriverInstance` INSERT (`'Northwind UNS placeholder', 'Modbus', …`) and its
comment.
- **Equipment INSERT**: drop `DriverInstanceId` from the column list (→ NULL).
- Retire the `EQ_DRIVER = "nw-uns-modbus"` constant and the now-dead `DELETE … Tag WHERE DriverInstanceId`
/ `DELETE … DriverInstance` teardown lines (in both `cmd_populate_equipment` and `cmd_clean`) — they
become no-ops; remove for clarity. Keep the `Namespace` INSERT/teardown.
- Fix the stale `# … "an Equipment namespace has a driver" expectations` comment.
### 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: guard `e.DriverInstanceId != null && …` (SQL already excludes NULL from an `IN` set, so this only satisfies the compiler).
- `Components/Pages/Clusters/EquipmentEdit.razor` — the equipment editor loads `DriverInstanceId` into a non-null
`FormModel` (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_ComputeGenerationDiff` includes `DriverInstanceId` in a `CHECKSUM(...)`. 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 with `ISNULL(DriverInstanceId, '')` as a follow-on.
## Testing & verification
1. **Unit (Configuration tests):** a `DraftValidator` test that a draft with driver-less equipment
(`Equipment.DriverInstanceId == null`, Equipment namespace with zero drivers) validates clean.
2. **Unit (Runtime tests):** a `BuildClusterSets` / scoped-`ParseComposition` test proving a null-driver
equipment is attributed to the cluster of its line's area in `ScopeTo` mode (and its VirtualTags are
kept), while a wrong-cluster line excludes it.
3. Existing `Configuration`, `OpcUaServer` (Phase7), and `Runtime` (DeploymentArtifact) suites stay green.
4. **Migration** authored + applied to the docker-dev config DB.
5. **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
contain** `spawned Modbus driver nw-uns-modbus` or `missing 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 | lowmedium — 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` (off `master` `446a456`).
- 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.
@@ -0,0 +1,244 @@
# Driver-less Equipment Namespace — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Let VirtualTag-only equipment reference no field driver — make `Equipment.DriverInstanceId` nullable, delete the misleading `Modbus` placeholder driver, and stop the per-deploy stub/exception noise, while preserving cluster-attribution correctness.
**Architecture:** The data path is unchanged (equipment values stream from the GalaxyMxGateway driver via the galaxy-mirror → VirtualTag indirection). Almost nothing reads `Equipment.DriverInstanceId`, so the change is: (1) make the column nullable (entity + one EF migration), (2) fix the single cluster-attribution site (`DeploymentArtifact.BuildClusterSets`) to anchor driver-less equipment on its UNS line's area cluster instead of its driver, (3) update the loader to stop creating the placeholder and NULL the FK, (4) live-verify on docker-dev.
**Tech Stack:** .NET 10, EF Core (MSSQL), Akka.NET, xUnit + Shouldly. Solution `ZB.MOM.WW.OtOpcUa.slnx`, Central Package Management. Branch `feat/driverless-equipment-namespace` (off `master` `446a456`). Loader: Python (`scadaproj/otopcua-uns-loader/otopcua_uns.py`). Design: `docs/plans/2026-06-08-driverless-equipment-namespace-design.md`.
**Sequencing:** T1 (entity + migration) first — it's the schema foundation. T2 (BuildClusterSets) and T3 (validator test) build on T1; T4 (loader) is a different repo and runs anytime. T5 (live verify) needs T1+T2+T4.
---
### Task 1: Make `Equipment.DriverInstanceId` nullable + EF migration
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4 (different repo)
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs` (line ~23)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor` (~line 191 — null-guard the EF predicate)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor` (FormModel + dropdown + save — full driver-less support)
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/<timestamp>_NullableEquipmentDriverInstanceId.cs` (generated by `dotnet ef`)
- Auto-modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs` (regenerated by `dotnet ef`)
**AdminUI surface (found at build time — the nullable change breaks two `.razor` sites under `TreatWarningsAsErrors`):**
- `TagEdit.razor:191`: `db.Equipment.Where(e => driverIds.Contains(e.DriverInstanceId))` → guard `e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId)` (behavior-preserving; SQL already excludes NULL).
- `EquipmentEdit.razor`: full driver-less support — `FormModel.DriverInstanceId``string?`; add a "(none / driver-less)" option to the driver `<select>`; remove the mandatory `if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }` check (~line 209); on save (both the IsNew `Add` ~line 222 and the update ~line 245) write `string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId`.
**Context:** `DriverInstanceId` is currently `public required string DriverInstanceId { get; set; }`. The EF config `OtOpcUaConfigDbContext.ConfigureEquipment` has **no `.IsRequired()`** call and **no FK relationship** for it (it's a logical FK only) — so EF infers NOT NULL purely from the C# `required string` type. Changing the type to `string?` flips the column to nullable; no fluent-config change is needed. The index `IX_Equipment_Driver` is a plain non-unique index and stays valid on a nullable column.
**Step 1 — Change the entity property.** In `Equipment.cs`, change:
```csharp
public required string DriverInstanceId { get; set; }
```
to:
```csharp
public string? DriverInstanceId { get; set; }
```
(Drop the `required` modifier and make the type nullable. Update the property's XML-doc comment if it asserts non-null — note it is now optional: null = VirtualTag-only / driver-less equipment.)
**Step 2 — Build to confirm no compile break from the nullability change.**
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
Expected: build succeeds. If any production code dereferences `Equipment.DriverInstanceId` with a non-null assumption (`!` or implicit), the nullable-reference analyzer will flag it under `TreatWarningsAsErrors` — the investigation found NONE in production code, so expect 0 new warnings. If one appears, STOP and report the file:line (it's an undocumented deref the design missed).
**Step 3 — Author the migration.** Confirm the EF tool is available (`dotnet ef --version`; if missing, `dotnet tool restore` or `dotnet tool install --global dotnet-ef`). Set the design-time connection env (no DB connection is made during `migrations add` — it diffs the model), then scaffold:
```bash
export OTOPCUA_CONFIG_CONNECTION="Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
dotnet ef migrations add NullableEquipmentDriverInstanceId --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration
```
**Inspect the generated migration.** It MUST be a single `AlterColumn` and its inverse — nothing else:
```csharp
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId", table: "Equipment",
type: "nvarchar(64)", maxLength: 64, nullable: true,
oldClrType: typeof(string), oldType: "nvarchar(64)", oldMaxLength: 64);
// Down(): the same AlterColumn with nullable: false / oldNullable: true
```
If the migration contains anything beyond this `AlterColumn` (e.g. unrelated model drift), STOP and report — the model snapshot may be out of sync with master and that must be resolved first.
**Step 4 — Build + run the Configuration test suite (behaviour-preserving).**
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` then `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/` (glob `tests` for the exact path if it differs).
Expected: green. Nothing asserts `DriverInstanceId` non-null, so all existing tests pass.
**Step 5 — Commit.**
```bash
git add -A && git commit -m "feat(config): make Equipment.DriverInstanceId nullable (driver-less equipment) + migration"
```
**Acceptance:** entity is `string?`; the new migration is a single `AlterColumn(... nullable: true)`; the model snapshot drops `.IsRequired()` on `DriverInstanceId`; solution builds; Configuration tests green. **Do NOT apply the migration to any DB here** — that happens in Task 5.
---
### Task 2: `BuildClusterSets` — attribute driver-less equipment via its UNS line's area cluster
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3 (different file), Task 4 (different repo)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildClusterSets`, ~lines 271297, and its `ClusterSets` doc ~260267)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/...` (find the existing DeploymentArtifact test file by glob, e.g. `*DeploymentArtifact*Tests.cs` or `*ParseComposition*`/`*ClusterScope*`)
**Context:** Equipment carries no `ClusterId`. Today `BuildClusterSets` attributes equipment to a cluster **via its driver**:
```csharp
// Equipment carries no ClusterId — include it when its DriverInstanceId is in-cluster.
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && di is not null && driverIds.Contains(di))
equipmentIds.Add(id!);
```
A null driver ⇒ equipment (and its VirtualTags) silently dropped in multi-cluster (`ScopeTo`) mode. The fix anchors driver-less equipment on its UNS placement: `UnsArea` carries `ClusterId` (collected into `areaIds`), and `Equipment → UnsLine.UnsAreaId → UnsArea`. So a driver-less equipment is in-cluster iff its line's area is in-cluster. Single-cluster (`ClusterFilterMode.None`) never calls `BuildClusterSets`, so this only affects multi-cluster — but it's a correctness fix worth a test.
**Step 1 — Write the failing test.** First READ the existing DeploymentArtifact/cluster-scope test file to learn the harness: how a deployment-artifact JSON blob is built (the helper that assembles `DriverInstances`/`UnsAreas`/`UnsLines`/`Equipment`/`EquipmentVirtualTags` arrays), and how `ParseComposition(blob, scope)` / `BuildClusterSets` results are asserted. Mirror that harness exactly. Add a test like:
- `Driverless_equipment_is_kept_when_its_line_area_is_in_cluster`: blob with an in-cluster `UnsArea` (ClusterId = "C1"), a `UnsLine` in that area, an `Equipment` with **`DriverInstanceId: null`** on that line, plus an `EquipmentVirtualTag` for it. Call the cluster-scoped parse for cluster "C1". Assert the equipment node AND its VirtualTag are present in the result.
- `Driverless_equipment_is_excluded_when_its_line_area_is_in_another_cluster`: same but the area's ClusterId is "C2"; scope to "C1" → equipment + its vtag absent.
- (Keep/confirm an existing driver-bound case still works — driver-bound equipment attribution must be unchanged.)
If the existing tests assert via `ParseComposition(blob, ClusterScope)`, use that public entry; if they unit-test `BuildClusterSets` directly via an internal-visible hook, follow that. Do NOT invent a new harness.
**Step 2 — Run, confirm fail.** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ --filter "FullyQualifiedName~Driverless"` → the new tests fail (driver-less equipment currently dropped).
**Step 3 — Implement.** In `BuildClusterSets`, before the Equipment loop, build a `UnsLineId → UnsAreaId` map from the artifact's `UnsLines` array; then attribute driver-less equipment by line-area:
```csharp
// Map each UnsLine to its area so driver-less equipment can be cluster-attributed by its UNS placement.
var lineToArea = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array)
{
foreach (var el in lines.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null;
var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId))
lineToArea[lineId!] = areaId!;
}
}
// ... in the Equipment loop, replace the driver-only attribution with:
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var inClusterByDriver = di is not null && driverIds.Contains(di);
var inClusterByLine = di is null && lineId is not null
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
if (inClusterByDriver || inClusterByLine)
equipmentIds.Add(id!);
```
Update the `// Equipment carries no ClusterId — …` comment and the `ClusterSets.EquipmentIds` doc (~line 263) to state: driver-bound equipment is attributed by its driver's cluster; driver-less equipment by its UNS line's area cluster. Keep `driverIds`/`areaIds` collection unchanged. **Note:** `areaIds` is already populated (the `CollectIdsWhereCluster(root, "UnsAreas", …)` call) before the Equipment loop — confirm ordering so `areaIds` is filled first.
**Step 4 — Run, confirm pass** + the broader DeploymentArtifact/Runtime suite stays green:
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ --filter "FullyQualifiedName~DeploymentArtifact"` (and the `Driverless` filter). Also `dotnet build ZB.MOM.WW.OtOpcUa.slnx`.
**Step 5 — Commit.**
```bash
git add -A && git commit -m "fix(deploy): cluster-attribute driver-less equipment via its UNS line area (BuildClusterSets)"
```
**Acceptance:** driver-less equipment + its VirtualTags are kept in `ScopeTo` mode when the line's area is in-cluster, excluded otherwise; driver-bound attribution unchanged; suite green.
---
### Task 3: `DraftValidator` accepts a driver-less-equipment draft
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2 (different file), Task 4 (different repo)
**Files:**
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/...` (find the existing `DraftValidator` test file by glob, e.g. `*DraftValidator*Tests.cs`)
**Context:** No validator rule dereferences `Equipment.DriverInstanceId` or requires an Equipment namespace to have a driver. This task LOCKS that in with a regression test (no production change). It proves the design's core safety claim: a draft with driver-less equipment + an Equipment-kind namespace that has zero `DriverInstance` rows validates clean.
**Step 1 — Write the test.** READ the existing `DraftValidator` tests to learn how a `DraftSnapshot` is constructed in this suite (the builder/fixture for Equipment, Namespaces, DriverInstances, UnsAreas/Lines, ServerCluster/ClusterNode). Then add:
- `Validate_accepts_driverless_equipment_in_driverless_equipment_namespace`: build a minimal valid draft with an Equipment-kind namespace that has **zero `DriverInstance` rows**, and one `Equipment` with **`DriverInstanceId = null`** (canonical `EQ-…` id, valid Name/UnsLineId, with the UnsLine/UnsArea/cluster wiring the other rules need), plus its VirtualTag. Assert `DraftValidator.Validate(snapshot)` returns **zero errors** (`result.Errors.ShouldBeEmpty()` or the suite's idiom).
Reuse whatever canonical-equipment helper the suite already has (the EquipmentId must satisfy `ValidateEquipmentIdDerivation` = `"EQ-" + uuid.ToString("N")[:12].ToLowerInvariant()`); copy that derivation from an existing passing test so the test isn't tripped by an unrelated rule.
**Step 2 — Run, confirm pass.** `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ --filter "FullyQualifiedName~DraftValidator"` → green (this is a characterization test; it should pass immediately on top of Task 1's nullable entity). If it FAILS, that means a rule DOES implicitly require a driver — STOP and report which error code fired (the design would need revisiting).
**Step 3 — Commit.**
```bash
git add -A && git commit -m "test(config): DraftValidator accepts driver-less equipment + driverless equipment namespace"
```
**Acceptance:** the new test passes; existing DraftValidator tests stay green.
---
### Task 4: Loader — stop creating the placeholder, NULL the equipment FK
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3 (different repo)
**Files:**
- Modify: `/Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py` (`cmd_populate_equipment`, `cmd_clean`, the `EQ_DRIVER` constant + its comment ~lines 8691)
**Context:** The loader invents a placeholder `Modbus` `DriverInstance` (`nw-uns-modbus`) and points all 40 equipment at it. With the column now nullable (Task 1), equipment should reference NO driver.
**Step 1 — Edit `cmd_populate_equipment`.** READ the function first. Then:
- **Remove** the placeholder `DriverInstance` INSERT (the `... VALUES (NEWID(), %s, %s, %s, 'Northwind UNS placeholder', 'Modbus', 1, '{}')` block, ~lines 275278) and its preceding "Placeholder driver…" comment.
- **Equipment INSERT** (~lines 296299): drop `DriverInstanceId` from the column list and its value param entirely, so the row inserts with a NULL driver. E.g.:
```python
cur.execute(
"INSERT INTO dbo.Equipment (EquipmentRowId, EquipmentId, EquipmentUuid, UnsLineId, "
"Name, MachineCode, Manufacturer, Model, Enabled) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, 1)",
(eq_id, eq_uuid, "nw-" + e["unsLineId"], e["name"], e["machineCode"],
e.get("manufacturer"), e.get("model")))
```
(Verify the exact column/param alignment against the current code — remove exactly the `DriverInstanceId` column and the `EQ_DRIVER` arg, keep everything else.)
- In the teardown block at the top of `cmd_populate_equipment` (the DELETEs), **remove** the now-dead `DELETE FROM dbo.Tag WHERE DriverInstanceId=%s, (EQ_DRIVER,)` and `DELETE FROM dbo.DriverInstance WHERE DriverInstanceId=%s, (EQ_DRIVER,)` lines (no such rows are created anymore). Keep the VirtualTag/Script/Equipment/UnsLine/UnsArea/Namespace deletes.
**Step 2 — Edit `cmd_clean`** the same way: remove the `DELETE … Tag WHERE DriverInstanceId=%s` and `DELETE … DriverInstance WHERE DriverInstanceId=%s` lines that reference `EQ_DRIVER`.
**Step 3 — Retire the constant + fix the comment.** Remove the `EQ_DRIVER = "nw-uns-modbus"` constant and the stale comment block (~lines 8691) that explains the placeholder ("A placeholder non-Galaxy driver kept ONLY to satisfy 'an Equipment namespace has a driver' expectations…"). If any other reference to `EQ_DRIVER` remains, grep `EQ_DRIVER` across the file and remove/adjust each.
**Step 4 — Smoke-check the script parses.** `python3 -c "import ast; ast.parse(open('/Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py').read())"` → no SyntaxError. (Do NOT run populate against the DB here — that's Task 5, after the migration is applied.) Confirm no lingering `EQ_DRIVER` references: `grep -n EQ_DRIVER /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader/otopcua_uns.py` → no output.
**Step 5 — Commit (in the scadaproj repo).**
```bash
cd /Users/dohertj2/Desktop/scadaproj && git add otopcua-uns-loader/otopcua_uns.py \
&& git commit -m "loader: equipment is driver-less (drop Modbus placeholder, NULL DriverInstanceId)"
```
(Commit on `scadaproj` `main` — the loader lives there; do NOT push.)
**Acceptance:** no `EQ_DRIVER` references remain; the placeholder DriverInstance INSERT is gone; the Equipment INSERT omits `DriverInstanceId`; the script parses; teardown still cleans the overlay.
---
### Task 5: Live docker-dev verification
**Classification:** standard
**Estimated implement time:** ~6 min (+ migration/deploy/settle)
**Parallelizable with:** none (needs T1, T2, T4)
**Steps (no new code — the proof the wart is gone and nothing regressed):**
1. **Build the docker-dev image off this branch** (carries the nullable entity + BuildClusterSets fix):
`cd docker-dev && docker compose build central-1`.
2. **Apply the migration to the docker-dev config DB.** The DB is NOT auto-migrated. Run EF update against the live config DB (port 14330):
```bash
export OTOPCUA_CONFIG_CONNECTION="Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
dotnet ef database update --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration
```
Confirm: `Applied migration '<...>_NullableEquipmentDriverInstanceId'`. Sanity-check the column is now nullable:
`docker exec otopcua-dev-sql-1 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -C -d OtOpcUa -Q "SELECT is_nullable FROM sys.columns WHERE object_id=OBJECT_ID('Equipment') AND name='DriverInstanceId';"` → `1`.
3. **Re-run the loader driver-less** (clears + reloads the equipment overlay with NULL driver, no placeholder):
`cd /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader && .venv/bin/python otopcua_uns.py populate-equipment` (use the same invocation the project README/memory documents). Verify in SQL: `SELECT COUNT(*) FROM Equipment WHERE DriverInstanceId IS NULL` = 40; `SELECT COUNT(*) FROM DriverInstance WHERE DriverInstanceId='nw-uns-modbus'` = 0.
4. **Recreate the admin nodes on the new image** (sites untouched):
`cd docker-dev && docker compose up -d --no-deps --force-recreate central-1 central-2`. Wait for `:9200` to answer (302).
5. **Redeploy headless:** `curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key'` → expect **202 Accepted**.
6. **Verify values still flow:** `cd /Users/dohertj2/Desktop/scadaproj/otopcua-uns-loader && .venv/bin/python otopcua_uns.py verify-equipment --require-good 396 --wait` → **`VERIFY-EQUIPMENT: PASS`**, 396 Good.
7. **The wart is gone — confirm the Modbus stub/exception no longer appears:**
`docker logs otopcua-dev-central-1-1 2>&1 | grep -iE "nw-uns-modbus|missing required Host|spawned Modbus driver"` → **no output** (previously this showed `factory for Modbus threw on nw-uns-modbus` + `spawned Modbus driver nw-uns-modbus (stub=True)`).
**Acceptance:** migration applied (column nullable); 40 equipment rows have NULL driver and the `nw-uns-modbus` DriverInstance is gone; deploy 202 Accepted; `verify-equipment` PASS (396 Good); central-1 log clean of the Modbus stub/exception. **This task changes the running docker-dev stack (the user's active env)** — coordinate: recreate only the admin nodes; don't disrupt the site nodes.
---
## After all tasks
Run the affected suites (`Configuration`, `Runtime` DeploymentArtifact) + build the solution, then use **superpowers-extended-cc:finishing-a-development-branch**: verify green, present merge/PR/keep/discard for `feat/driverless-equipment-namespace` (OtOpcUa) and the `scadaproj` loader commit. Merge/push only on the user's explicit go (the user manages this repo's integration).
@@ -0,0 +1,11 @@
{
"planPath": "docs/plans/2026-06-08-driverless-equipment-namespace.md",
"tasks": [
{"id": 1, "nativeId": 143, "subject": "Task 1: Equipment.DriverInstanceId nullable + EF migration + AdminUI driver-less support", "status": "completed", "classification": "high-risk"},
{"id": 2, "nativeId": 144, "subject": "Task 2: BuildClusterSets driver-less equipment attribution", "status": "completed", "classification": "standard", "blockedBy": [1]},
{"id": 3, "nativeId": 145, "subject": "Task 3: DraftValidator accepts driver-less equipment (test)", "status": "completed", "classification": "small", "blockedBy": [1]},
{"id": 4, "nativeId": 146, "subject": "Task 4: Loader — driver-less equipment (+ self-heal legacy placeholder)", "status": "completed", "classification": "small"},
{"id": 5, "nativeId": 147, "subject": "Task 5: Live docker-dev verification (driver-less equipment, no Modbus stub)", "status": "completed", "classification": "standard", "blockedBy": [1, 2, 4]}
],
"lastUpdated": "2026-06-08"
}
@@ -19,8 +19,11 @@ public sealed class Equipment
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Optional logical FK to the driver providing data for this equipment.
/// <c>null</c> = VirtualTag-only / driver-less equipment (no field driver).
/// </summary>
public string? DriverInstanceId { get; set; }
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
public string? DeviceId { get; set; }
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class NullableEquipmentDriverInstanceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// WARNING: this rollback converts any existing NULL (driver-less) rows to "" for DriverInstanceId.
// Only safe when no driver-less equipment exists in the database.
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -546,7 +546,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -23,7 +23,8 @@
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is
Equipment rows are scoped to a UNS line and optionally bound to a driver instance
(driver-less = VirtualTag-only). EquipmentId is
system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag
(ERP).
</section>
@@ -83,8 +84,15 @@ else
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId);
// Driver-less equipment (DriverInstanceId == null) has no DriverInstance FK.
// Scope it to this cluster via UnsLine → UnsArea.ClusterId instead.
var areaIds = db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId);
var linesInCluster = db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId)).Select(l => l.UnsLineId);
_rows = await db.Equipment.AsNoTracking()
.Where(e => driversInCluster.Contains(e.DriverInstanceId))
.Where(e => driversInCluster.Contains(e.DriverInstanceId)
|| (e.DriverInstanceId == null && linesInCluster.Contains(e.UnsLineId)))
.OrderBy(e => e.Name)
.ToListAsync();
}
@@ -72,7 +72,7 @@ else
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver</option>
<option value="">(none / driver-less)</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name (@d.DriverType)</option>
@@ -206,7 +206,6 @@ else
try
{
if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; }
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -219,7 +218,7 @@ else
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = _form.DriverInstanceId,
DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId,
UnsLineId = _form.UnsLineId,
Name = _form.Name,
MachineCode = _form.MachineCode,
@@ -242,7 +241,7 @@ else
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = _form.DriverInstanceId;
entity.DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId;
entity.UnsLineId = _form.UnsLineId;
entity.Name = _form.Name;
entity.MachineCode = _form.MachineCode;
@@ -292,7 +291,7 @@ else
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
@@ -23,6 +23,7 @@
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Bulk import requires a driver; driver-less (VirtualTag-only) equipment is created via the single-add form.
Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are
detected by MachineCode and skipped (the importer is additive-only — no updates).
</section>
@@ -108,6 +109,9 @@
private bool _busy;
private string? _error;
// Bulk import requires a DriverInstanceId by design — every CSV row must reference an existing driver.
// Driver-less equipment (DriverInstanceId == null) is not supported via bulk import;
// create it via the single-add editor (/clusters/{id}/equipment/new) or the SQL loader.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"];
@@ -188,7 +188,7 @@ else
d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First());
var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet();
_equipment = await db.Equipment.AsNoTracking()
.Where(e => driverIds.Contains(e.DriverInstanceId))
.Where(e => e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId))
.OrderBy(e => e.MachineCode)
.ToListAsync();
@@ -209,9 +209,12 @@ public static class DeploymentArtifact
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
/// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
/// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
/// area is in-cluster.
/// When <paramref name="onInconsistency"/> is supplied it is invoked with a human-readable message for each
/// kept equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding that
/// violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
/// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding
/// that violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
/// detection only (observability); the equipment is still returned, since the upstream draft validator
/// is the authority that should prevent the binding in the first place.</summary>
/// <param name="blob">The deployment artifact blob.</param>
@@ -234,6 +237,9 @@ public static class DeploymentArtifact
if (onInconsistency is not null)
{
var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase);
// This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment
// is attributed by its UNS line's area cluster, so by construction its line is always in
// keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it.
foreach (var e in keptEquipment)
{
if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId))
@@ -260,11 +266,14 @@ public static class DeploymentArtifact
/// <summary>The in-cluster id sets used to filter a composition.</summary>
/// <param name="DriverIds">DriverInstanceIds whose row carries the in-scope ClusterId.</param>
/// <param name="AreaIds">UnsAreaIds whose row carries the in-scope ClusterId.</param>
/// <param name="EquipmentIds">EquipmentIds whose owning DriverInstanceId is in-cluster.</param>
/// <param name="EquipmentIds">In-cluster EquipmentIds: driver-bound equipment is attributed by its
/// driver's cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.</param>
private sealed record ClusterSets(HashSet<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
/// <summary>Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
/// that directly carry the ClusterId, plus EquipmentIds whose DriverInstanceId is in-cluster.</summary>
/// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
/// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
/// line's area cluster.</summary>
/// <param name="blob">The deployment artifact blob.</param>
/// <param name="clusterId">The node's ClusterId to scope to.</param>
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
@@ -279,7 +288,22 @@ public static class DeploymentArtifact
var root = doc.RootElement;
CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
// Equipment carries no ClusterId — include it when its DriverInstanceId is in-cluster.
// Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via
// its line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
var lineToArea = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array)
{
foreach (var el in lines.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null;
var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId))
lineToArea[lineId!] = areaId!;
}
}
// Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
{
foreach (var el in eq.EnumerateArray())
@@ -287,8 +311,12 @@ public static class DeploymentArtifact
if (el.ValueKind != JsonValueKind.Object) continue;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && di is not null && driverIds.Contains(di))
equipmentIds.Add(id!);
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var inByDriver = di is not null && driverIds.Contains(di);
var inByLine = di is null && lineId is not null
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
if (inByDriver || inByLine) equipmentIds.Add(id!);
}
}
}
@@ -297,6 +297,65 @@ public sealed class DraftValidatorTests
string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")));
}
// ------------------------------------------------------------------------------------
// Driver-less Equipment namespace — core safety claim (feat/driverless-equipment-namespace)
// ------------------------------------------------------------------------------------
/// <summary>Characterisation test: a draft that contains an Equipment-kind namespace with
/// ZERO <see cref="DriverInstance"/> rows, and a single <see cref="Equipment"/> whose
/// <see cref="Equipment.DriverInstanceId"/> is <see langword="null"/>, must pass
/// <see cref="DraftValidator.Validate"/> with no errors. This locks in the design's core
/// safety claim — the validator must never implicitly require a driver for Equipment-namespace
/// equipment.</summary>
[Fact]
public void Validate_accepts_driverless_equipment_in_driverless_equipment_namespace()
{
// An Equipment-kind namespace — no DriverInstance rows at all for this namespace.
var eqNamespace = new Namespace
{
NamespaceId = "MAIN-OPCUA-equipment",
ClusterId = "MAIN",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:main:equipment",
};
// UNS topology required by ValidateUnsSegments / ValidatePathLength.
var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" };
// Canonical EquipmentId derived from UUID — satisfies ValidateEquipmentIdDerivation.
var uuid = Guid.NewGuid();
var equipment = new Equipment
{
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = "rinser-01",
DriverInstanceId = null, // ← driver-less: the property under test
UnsLineId = line.UnsLineId,
MachineCode = "machine_001",
ZTag = null,
SAPID = null,
};
var draft = new DraftSnapshot
{
GenerationId = 0,
ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync
Namespaces = [eqNamespace], // Equipment namespace present
DriverInstances = [], // ← zero drivers: the other half of the safety claim
UnsAreas = [area],
UnsLines = [line],
Equipment = [equipment],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldBeEmpty(
"a driver-less Equipment in an Equipment-kind namespace with no DriverInstances must pass " +
"all validator rules; firing rules: " +
string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")));
}
// ------------------------------------------------------------------------------------
// ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision
// ------------------------------------------------------------------------------------
@@ -521,4 +521,128 @@ public sealed class DeploymentArtifactTests
comp.UnsLines.Select(l => l.UnsLineId).ShouldBe(new[] { "line-1" });
warnings.ShouldBeEmpty();
}
/// <summary>A multi-cluster artifact with one driver-less equipment (no <c>DriverInstanceId</c>
/// property at all => null driver) attributed only via its UNS line's area cluster:
/// UnsArea A1 (ClusterId = <paramref name="areaCluster"/>), UnsLine L1 (UnsAreaId = A1),
/// Equipment E1 (UnsLineId = L1, no driver), plus an EquipmentVirtualTag on E1. The MAIN-cluster
/// node always carries a driver so the artifact is genuinely multi-cluster (scoped, not None).</summary>
private static byte[] DriverlessEquipmentBlob(string areaCluster) => BlobOf(new
{
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
Nodes = new[]
{
new { NodeId = "c1-1:4053", ClusterId = "C1" },
new { NodeId = "c2-1:4053", ClusterId = "C2" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
},
UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = areaCluster } },
UnsLines = new[] { new { UnsLineId = "L1", UnsAreaId = "A1", Name = "Line1" } },
// Driver-less equipment: the DriverInstanceId property is absent => the parser sees null.
Equipment = new[]
{
new { EquipmentId = "E1", Name = "Equip1", UnsLineId = "L1" },
},
Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } },
VirtualTags = new[]
{
new { VirtualTagId = "vt-e1", EquipmentId = "E1", Name = "VE1", DataType = "Float", ScriptId = "scr" },
},
});
/// <summary>Verifies driver-less equipment (null DriverInstanceId) IS kept when its UNS line's area
/// is in the node's cluster — attributed via the line→area→cluster chain rather than via a driver.
/// Both the EquipmentNode and its EquipmentVirtualTag must survive the scoped parse.</summary>
[Fact]
public void Driverless_equipment_kept_when_its_line_area_is_in_cluster()
{
var comp = DeploymentArtifact.ParseComposition(
DriverlessEquipmentBlob(areaCluster: "C1"), "c1-1:4053");
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E1" });
comp.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-e1" });
}
/// <summary>Verifies driver-less equipment is EXCLUDED when its UNS line's area is in another cluster:
/// the node scopes to C1 but area A1 (and thus line L1, equipment E1) lives in C2.</summary>
[Fact]
public void Driverless_equipment_excluded_when_its_line_area_in_other_cluster()
{
var comp = DeploymentArtifact.ParseComposition(
DriverlessEquipmentBlob(areaCluster: "C2"), "c1-1:4053");
comp.EquipmentNodes.ShouldBeEmpty();
comp.EquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies driver-less equipment with no UnsLineId (null/absent) is EXCLUDED from the scoped
/// composition and throws nothing: without a line there is no area chain to resolve a cluster from.</summary>
[Fact]
public void Driverless_equipment_excluded_when_it_has_no_line()
{
var blob = BlobOf(new
{
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
Nodes = new[]
{
new { NodeId = "c1-1:4053", ClusterId = "C1" },
new { NodeId = "c2-1:4053", ClusterId = "C2" },
},
DriverInstances = new[]
{
// One driver per cluster so the artifact is genuinely multi-cluster (scoped, not None).
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
},
UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = "C1" } },
// Equipment has no DriverInstanceId (driver-less) and no UnsLineId — cannot resolve a cluster.
Equipment = new[]
{
new { EquipmentId = "E-noline", Name = "NoLine" },
},
Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } },
VirtualTags = new[]
{
new { VirtualTagId = "vt-noline", EquipmentId = "E-noline", Name = "VNL", DataType = "Float", ScriptId = "scr" },
},
});
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
comp.EquipmentNodes.ShouldBeEmpty();
comp.EquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies the unchanged driver-bound path: equipment with an in-cluster DriverInstanceId is
/// kept even when (as here) no UNS line/area rows describe it — attribution is still by driver.</summary>
[Fact]
public void Driverbound_equipment_kept_when_driver_in_cluster()
{
var blob = BlobOf(new
{
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
Nodes = new[]
{
new { NodeId = "c1-1:4053", ClusterId = "C1" },
new { NodeId = "c2-1:4053", ClusterId = "C2" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
},
Equipment = new[]
{
new { EquipmentId = "E-c1", Name = "Eq-c1", UnsLineId = "l1", DriverInstanceId = "c1-driver" },
new { EquipmentId = "E-c2", Name = "Eq-c2", UnsLineId = "l2", DriverInstanceId = "c2-driver" },
},
});
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" });
}
}