Compare commits
9 Commits
446a45686f
...
f5ef0991af
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ef0991af | |||
| b962b041f3 | |||
| d909a8e4f6 | |||
| 0b5fc44866 | |||
| 53eb3fcda5 | |||
| c688899134 | |||
| d2dbf7b0d7 | |||
| a94d03a194 | |||
| 064adb0bd0 |
@@ -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 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 → 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 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 `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 | 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` (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 271–297, and its `ClusterSets` doc ~260–267)
|
||||
- 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 86–91)
|
||||
|
||||
**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 275–278) and its preceding "Placeholder driver…" comment.
|
||||
- **Equipment INSERT** (~lines 296–299): 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 86–91) 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; }
|
||||
|
||||
+1758
File diff suppressed because it is too large
Load Diff
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -546,7 +546,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("DriverInstanceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
|
||||
+10
-2
@@ -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 — @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" });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user