diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md b/archreview/plans/R2-11-tagconfig-consolidation-plan.md
index 1e14a347..20fdafc2 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md
@@ -494,3 +494,31 @@ T9 ← T2 + T3 T21 ← T20
T10 ← T3 + T5 T22 ← independent (models only)
T23 ← T17 + T21 T24 ← everything
```
+
+---
+
+## Execution deviations (R2-11)
+
+- **T5 / T10 — ExtractTag* test deletion co-located into T5.** Deleting the composer's
+ `ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray` statics (T5) breaks compilation of
+ `OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests` (they call the now-deleted internals). To
+ keep T5's commit build-green — the plan's T5 step explicitly sanctions "deleting their direct-static
+ references now" — the three test files were removed in the T5 commit. T10 therefore ran as the
+ grep-sweep verification only (`MUST parse identically` → 0 hits in `src/`; the byte-parity authority is
+ `Commons.Tests/TagConfigIntentTests`). No coverage lost: every table row was ported to
+ `TagConfigIntentTests`, and the compose-level behaviour is held by the permanent
+ `Runtime.Tests/TagConfigCorpusParityTests` guard.
+- **T6 — composer device-host unit tests re-homed.** `AddressSpaceComposerDeviceHostTests`' two direct
+ `TryExtractDeviceHost` / `NormalizeDeviceHost` static tests were removed (the statics are deleted); their
+ semantics now live in `Commons.Tests/DeviceConfigIntentTests`. The suite keeps its Compose-level
+ `EquipmentNode.DeviceHost` projection coverage.
+- **T24 — whole-solution test sweep deferred-live.** The grep gates ran clean offline
+ (`grep "MUST parse identically" src/` = 0; `grep "private static TEnum ReadEnum" src/` = 0) and
+ `dotnet build ZB.MOM.WW.OtOpcUa.slnx` is **0 errors** (812 warnings are all pre-existing OTOPCUA0001 in
+ untouched `*.Tests` projects, none from R2-11). Per the executor memory constraint (integration suites
+ leak ~16 GB/run and other plans run concurrently), only the impacted UNIT suites were run filtered
+ throughout — all green. The whole-solution `dotnet test` is left as the serial heavy pass to run before
+ merge.
+- **T22 — Writable checkbox live-`/run` deferred.** AdminUI has no bUnit; the 9 model round-trip tests +
+ the razor compile are the offline proof. A docker-dev `/run` of one typed editor's Writable checkbox
+ (rebuild both central-1/2) is the flagged live gate before merge.
diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
index 46bbe08a..f37856f2 100644
--- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
+++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json
@@ -2,29 +2,29 @@
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
"lastUpdated": "2026-07-12",
"tasks": [
- { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "pending", "blockedBy": [] },
- { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "pending", "blockedBy": [] },
- { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "pending", "blockedBy": [] },
- { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "pending", "blockedBy": [] },
- { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] },
- { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
- { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
- { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] },
- { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "pending", "blockedBy": ["T2", "T3"] },
- { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "pending", "blockedBy": ["T3", "T5"] },
- { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "pending", "blockedBy": [] },
- { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] },
- { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "pending", "blockedBy": ["T17"] },
- { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "pending", "blockedBy": [] },
- { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
- { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "pending", "blockedBy": ["T20"] },
- { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "pending", "blockedBy": [] },
- { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "pending", "blockedBy": ["T17", "T21"] },
- { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "pending", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
+ { "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
+ { "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
+ { "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
+ { "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
+ { "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
+ { "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
+ { "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] },
+ { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] },
+ { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] },
+ { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] },
+ { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] },
+ { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
+ { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
+ { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] },
+ { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
+ { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] },
+ { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] },
+ { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] },
+ { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
]
}
diff --git a/archreview/plans/STATUS.md b/archreview/plans/STATUS.md
index 59eaad4e..a706bfef 100644
--- a/archreview/plans/STATUS.md
+++ b/archreview/plans/STATUS.md
@@ -175,6 +175,8 @@ what the unit leg cannot; (c) migrate the 3 xunit v2 holdouts once Akka ships an
| **R2-03** | 02/S13 (rank 5), 02/S12, 02/P7 | `r2/03-vt-failure-quality` (off `1676c8f4`) | `b9775485`…`6bb91e8d` (12 task commits) | **Code-complete, offline-verified.** S13: VT script failure/timeout degrades the node to `OpcUaQuality.Bad` (carrying last-known value) instead of freezing on last-Good forever — additive `EvaluationResult.Quality`, bridge historizes `0x80020000u` BadInternalError, published once per Good→Bad transition gated on all deps arrived (inputs-ready, no cold-start flash), recovery force-publishes next Good. S12: apply-boundary `ClearCompiledScripts` racing an in-flight eval into ObjectDisposedException retried once in `RoslynVirtualTagEvaluator` (re-fetch→recompile into current generation); stress guard reproduces pre-fix, deterministically green post-fix. P7: apply-boundary clear gated on a changed deployed expression set (`HashSet.SetEquals`, ordinal) — identical redeploy / rename-with-same-expressions no longer forces full VT recompile. Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20. **Deferred (T11/T12):** full cross-suite sweep + docker-dev live-`/run` (fail→Bad→recover + P7 no-recompile spot-check). |
| **R2-02** | 01/S-6 (High, rank 2), 01/U-6, 01/S-8=03/S12 (rank 4), 04/C-7, 01/S-7=03/S13 | `r2/02-resilience-config-hardening` (off `1676c8f4`) | `4be13429`…`dd82e740` (17 task commits) | **Code-complete, offline-verified.** **S-6:** parser clamps timeout/retry/breaker overrides (timeout≤0→tier default, breaker==1→2, caps) with diagnostics + belt-and-braces `Build` clamp so pipeline construction can never throw Polly `ValidationException` (new `ResiliencePolicyRanges` in Core.Abstractions); brick-repro + `Build_NeverThrows_ForHostileDirectOptions` + factory-seam wiring proof; AdminUI `Validate()` range warnings. **S-8:** parser forces Write/AlarmAck retry→0 (spec invariant) **and** dispatch flips to `isIdempotent:false` (RED-first on the Runtime wiring test); non-idempotent arm caches the no-retry snapshot once/invoker (01/P-4) + tracker in-flight accounting; `WriteIdempotentAttribute` false claim corrected; retry cells disabled. **U-6:** bulkhead knob deleted end-to-end (migration-free; stored keys ignored) + `Options_properties_are_exactly_the_pipeline_wired_set` inertness guard. **C-7:** `ResilienceFormModel` JsonObject-bag round-trip preserves unknown keys; malformed JSON → `ParseFailed` lock + verbatim `ToJson` + discard button. **S-7:** per-`Create` `Interlocked` options-generation in the pipeline-cache key kills the respawn stale-pipeline race. Core resilience 121/121, AdminUI form 10/10, Analyzers 32/32, Runtime 364/364; 0 production OTOPCUA0001. **Deferred (T15/T18):** docker-dev `:9200` `/run` (warning banner, input lockout, unknown-key survival) + whole-solution sweep. |
+| **R2-11** | 01/C-1, 01/P-1 + 05/CONV-2, UNDER-1, UNDER-6 | `r2/11-tagconfig-consolidation` (off `46fedda3`) | first `2e14fe1f` … (24 task commits) | **Code-complete, offline-verified.** **Part 1 (C-1/P-1):** single `TagConfigIntent.Parse` + `DeviceConfigIntent` in Commons replace the four byte-parity TagConfig parse copies (composer/artifact/walker/DraftValidator) — composer now parses once per tag (P-1); a permanent `Runtime.Tests/TagConfigCorpusParityTests` (30-blob corpus) guards compose≡decode; `grep "MUST parse identically" src/` = 0. **Part 2 (CONV-2):** shared strict-capable `TagConfigJson` readers in Core.Abstractions replace the six copy-pasted `ReadEnum`s; all six driver parsers gain `Inspect()` warnings + honour the `writable` key (FOCAS **forced read-only**, 05/UNDER-1); FOCAS equipment tags run the capability-matrix pre-flight (05/UNDER-6); Modbus probe parses the factory DTO shape (`timeoutMs` no longer dropped). New `EquipmentTagConfigInspector` in ControlPlane + `AdminOperationsActor` deploy-gate wiring with `Deployment:TagConfigValidationMode` (**Warn default | Error opt-in**), proven through the actor (F10b consuming test). AdminUI: `writable` checkbox in the six typed editors. **Phase-C follow-up (deferred, recorded here):** flip the runtime parsers to strict (typo'd enum ⇒ `BadNodeIdUnknown` instead of wrong-width `Good`) once fleets have run `Error` mode clean — behavior change deliberately not shipped in this plan. **Deferred (T22/T24):** docker-dev `/run` of one typed editor's Writable checkbox + whole-solution serial sweep. |
+
**Report anchor drifts to fold into the next report refresh** (from the R2-02 verification pass): (1)
`01-core-composition.md` U-6 anchor `DriverResilienceOptions.cs:23-25` → the two bulkhead properties sat at
`:23` and `:29` (now deleted); (2) `01-core-composition.md` U-6 anchor
diff --git a/docs/Uns.md b/docs/Uns.md
index 975a510d..cb0dfa3b 100644
--- a/docs/Uns.md
+++ b/docs/Uns.md
@@ -231,6 +231,53 @@ Combined with historization (values are arrays — history of the whole array sn
See the individual driver docs under `docs/drivers/` for per-driver implementation details.
+## Writable equipment tags (`writable` key)
+
+An equipment tag is **writable by default** — the materialised OPC UA node is created
+`CurrentReadWrite` and inbound client writes are routed to the device (subject to the
+`WriteOperate` LDAP role gate). Setting the optional `"writable": false` key in a tag's
+`TagConfig` makes the node **read-only** at the driver seam. The six typed tag editors
+(Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) expose this as a **Writable** checkbox
+(checked by default); it can also be hand-authored in the raw-JSON textarea.
+
+| Condition | Node |
+|---|---|
+| `writable` absent | Writable (driver default) — existing configs are unchanged |
+| `writable: false` | Read-only |
+| `writable: true` | Writable |
+
+The key is honoured by Modbus, S7, AB CIP, AB Legacy, and TwinCAT. **FOCAS forces
+read-only regardless** — FOCAS PMC writes are unsupported in the wire client, so its
+editor shows the checkbox disabled/unchecked and any authored `writable:true` is ignored
+(and flagged at the deploy gate below). Node-level authz (LDAP `WriteOperate`) remains the
+effective write gate above this flag.
+
+## Deploy-time TagConfig validation (`Deployment:TagConfigValidationMode`)
+
+The deploy gate inspects every equipment tag's `TagConfig` per its bound driver type and
+surfaces **strictness warnings** — a present-but-invalid enum value (e.g. a typo'd
+`dataType`, which the lenient runtime silently defaults to the wrong width with `Good`
+status), a structurally-unparseable TagConfig, or (FOCAS) an authored `writable:true`.
+
+The behaviour is controlled by `Deployment:TagConfigValidationMode` in `appsettings.json`:
+
+| Mode | Behaviour |
+|---|---|
+| `Warn` (default) | Non-blocking: warnings are logged and appended to the deployment result message; the deploy still succeeds. Existing configs never break. |
+| `Error` | Blocking: warnings fold into the draft-gate reject list — a config with a typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment once the fleet deploys warning-clean. |
+
+Running servers are untouched either way — the gate only sees re-deploys. Unmapped drivers
+(Galaxy, OpcUaClient) are skipped. **Unknown keys are never warned on**: the `TagConfig`
+blob is a shared namespace (platform intent keys coexist with per-driver address keys and
+forward-compat keys the typed editors preserve), so there is no single schema to validate
+against — parsers read known fields and ignore the rest by design.
+
+> **Note (runtime strictness is deferred).** In this release the runtime parsers stay
+> *lenient* (a typo'd enum still silently defaults) — only the deploy gate reports it.
+> Flipping the runtime parsers to strict (a typo'd enum ⇒ `BadNodeIdUnknown` instead of a
+> wrong-width `Good`) is a deliberate future phase, to be taken only after fleets have run
+> `Error` mode clean. Tracked in `archreview/plans/STATUS.md`.
+
## Galaxy address picker — native-alarm pre-fill
When the Galaxy address picker selects an attribute that is itself an alarm
diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
index 0507c775..83504579 100644
--- a/docs/drivers/FOCAS.md
+++ b/docs/drivers/FOCAS.md
@@ -15,6 +15,16 @@ OtOpcUa is **read-only** against FOCAS; all reads go over the native wire
protocol using the documented command IDs. Writes return
`BadNotWritable` by design.
+> **Equipment tags are forced read-only (R2-11, migration note).** The equipment-tag parser
+> (`FocasEquipmentTagParser`) now hard-codes `Writable: false` regardless of any authored
+> `"writable": true` — previously such a tag advertised a writable node whose write always failed at
+> the wire with `BadNotWritable`; now it fails one step earlier at the driver seam (same family of Bad
+> status, no successful operation changes). The AdminUI FOCAS editor shows the Writable checkbox
+> disabled/unchecked, and the deploy gate warns when a config requests `writable:true`. Additionally,
+> equipment-tag references now run the same capability-matrix pre-flight authored tags get: a reference
+> whose address violates the device series' matrix fails to resolve (`BadNodeIdUnknown`) instead of
+> reaching the wire. Honour `writable` here once PMC writes ship in the wire client.
+
> **PDU version (v3 / FANUC 30i/31i).** The wire client **accepts inbound PDU versions `{1, 3}`**
> (`FocasWireProtocol`) while still emitting v1 on requests. Older controls + the docker mock answer
> v1; modern controls (Makino Pro 5 / FANUC **31i-B**) answer v3. **Validated live against a real
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
new file mode 100644
index 00000000..cca42273
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// SINGLE SOURCE OF TRUTH for extracting + normalizing a Device's connection host from its
+/// schemaless DeviceConfig JSON. Shared by the live-compose seam (AddressSpaceComposer),
+/// the artifact-decode seam (DeploymentArtifact), and the FixedTree-partition path
+/// (DriverHostActor) so an EquipmentNode.DeviceHost and a driver-discovered device-host
+/// folder segment for the same device compare byte-equal. Ported verbatim from the historical
+/// AddressSpaceComposer.TryExtractDeviceHost / NormalizeDeviceHost.
+///
+public static class DeviceConfigIntent
+{
+ ///
+ /// Extract a Device's connection host from its schemaless DeviceConfig JSON: the
+ /// top-level "HostAddress" string (e.g. "10.201.31.5:8193"). Returns
+ /// when the config is blank, not a JSON object, has no string
+ /// HostAddress, or the value is blank/whitespace. Never throws. The returned host is
+ /// deterministically normalized via (trim + lower-case).
+ ///
+ /// The device's schemaless DeviceConfig JSON blob.
+ /// The normalized device host, or when absent/blank/unparseable.
+ public static string? TryExtractHost(string? deviceConfigJson)
+ {
+ if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(deviceConfigJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
+ || hostEl.ValueKind != JsonValueKind.String) return null;
+ var raw = hostEl.GetString();
+ if (string.IsNullOrWhiteSpace(raw)) return null;
+ return NormalizeHost(raw);
+ }
+ catch (JsonException) { return null; }
+ }
+
+ ///
+ /// The single normalization authority for a device host: trims surrounding whitespace and
+ /// lower-cases (invariant). Idempotent (an already-normalized value is unchanged).
+ ///
+ /// The raw host string (non-null; a non-empty HostAddress or folder segment).
+ /// The normalized host (trimmed + lower-cased).
+ public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant();
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
new file mode 100644
index 00000000..ba81fcf2
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
@@ -0,0 +1,129 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON.
+/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
+/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft
+/// gate (DraftValidator), and the walker (EquipmentNodeWalker). One
+/// per blob. Never throws.
+/// Two deliberate variations are carried on the record so every seam can be served from one
+/// parse:
+///
+/// is the driver-side wire reference: the top-level
+/// FullName string when present, else the RAW blob (an equipment tag's TagConfig JSON
+/// is its wire reference for the six protocol drivers). Parse(null) ⇒
+/// FullName == "".
+/// is the FullName property ONLY when
+/// present-and-string, else — the DraftValidator Galaxy-rule semantics
+/// (it wants the explicit reference, not the raw-blob fallback).
+///
+///
+/// Top-level FullName string, else the raw blob (wire-reference fallback).
+/// The FullName property when present-and-string, else .
+/// The optional native-alarm intent parsed from the alarm object; ⇒ plain value variable.
+/// isHistorized — true only when the value is a bool true.
+/// Non-whitespace historianTagname string override, else (not trimmed).
+/// isArray bool.
+/// arrayLength honoured ONLY when and a JSON number fitting ; else .
+public sealed record TagConfigIntent(
+ string FullName,
+ string? ExplicitFullName,
+ TagAlarmIntent? Alarm,
+ bool IsHistorized,
+ string? HistorianTagname,
+ bool IsArray,
+ uint? ArrayLength)
+{
+ ///
+ /// Parse a Tag's schemaless TagConfig JSON into the cross-driver platform intents.
+ /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
+ /// = the raw blob (or "" for null). Semantics are ported verbatim from the
+ /// historical AddressSpaceComposer.Extract* statics (the canonical copy).
+ ///
+ /// The tag's raw TagConfig JSON (nullable/blank tolerated).
+ /// The parsed intents; never .
+ public static TagConfigIntent Parse(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig))
+ return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
+
+ var explicitFullName =
+ root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
+ ? fn.GetString()
+ : null;
+ var fullName = explicitFullName ?? tagConfig;
+
+ var (isHistorized, historianTagname) = ParseHistorize(root);
+ var (isArray, arrayLength) = ParseArray(root);
+
+ return new TagConfigIntent(
+ fullName, explicitFullName, ParseAlarm(root),
+ isHistorized, historianTagname, isArray, arrayLength);
+ }
+ catch (JsonException)
+ {
+ return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
+ }
+ }
+
+ private static TagAlarmIntent? ParseAlarm(JsonElement root)
+ {
+ if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
+ var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
+ ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
+ var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
+ && sEl.TryGetInt32(out var sv) ? sv : 500;
+ // historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
+ // durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
+ bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
+ && hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? hEl.GetBoolean()
+ : null;
+ return new TagAlarmIntent(type, sev, historize);
+ }
+
+ private static (bool IsHistorized, string? HistorianTagname) ParseHistorize(JsonElement root)
+ {
+ var isHistorized = root.TryGetProperty("isHistorized", out var hEl)
+ && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
+ && hEl.GetBoolean();
+ string? tagname = null;
+ if (root.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String)
+ {
+ var raw = nEl.GetString();
+ if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; // not trimmed
+ }
+ return (isHistorized, tagname);
+ }
+
+ private static (bool IsArray, uint? ArrayLength) ParseArray(JsonElement root)
+ {
+ var isArray = root.TryGetProperty("isArray", out var aEl)
+ && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
+ && aEl.GetBoolean();
+ uint? arrayLength = null;
+ if (isArray
+ && root.TryGetProperty("arrayLength", out var lEl)
+ && lEl.ValueKind == JsonValueKind.Number
+ && lEl.TryGetUInt32(out var len))
+ {
+ arrayLength = len;
+ }
+ return (isArray, arrayLength);
+ }
+}
+
+/// The optional native-alarm intent parsed from a tag's TagConfig.alarm object.
+/// OPC UA Part 9 subtype string; default "AlarmCondition".
+/// 1..1000 severity; default 500.
+/// Per-condition durable-write opt-out (bool?; absent ⇒ ⇒ historize).
+public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index b5ba029d..e1c18e8e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -47,29 +48,16 @@ public static class DraftValidator
if (t.EquipmentId is null) continue;
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
continue;
- if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig)))
+ // The Galaxy rule wants the EXPLICIT reference, not the raw-blob fallback — so it reads
+ // TagConfigIntent.ExplicitFullName (the "FullName" property only when present-and-string,
+ // else null), preserving the historical null-on-absent semantics by construction (R2-11).
+ if (string.IsNullOrWhiteSpace(TagConfigIntent.Parse(t.TagConfig).ExplicitFullName))
errors.Add(new("GalaxyTagMissingReference",
$"Galaxy tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
t.TagId));
}
}
- // Minimal reader for the top-level "FullName" string in a tag's schemaless TagConfig JSON
- // (mirrors AddressSpaceComposer.ExtractTagFullName — a small local copy, consistent with this codebase
- // where the composer keeps its own).
- private static string? ExtractTagConfigFullName(string? tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return null;
- try
- {
- using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
- return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
- && doc.RootElement.TryGetProperty("FullName", out var fn)
- && fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
- }
- catch (System.Text.Json.JsonException) { return null; }
- }
-
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
index aaf2d613..4b014f63 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
@@ -27,6 +27,9 @@
+
+
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs
new file mode 100644
index 00000000..950b08a9
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs
@@ -0,0 +1,100 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+/// The outcome of reading an enum-valued field from a TagConfig JSON object.
+public enum JsonEnumRead
+{
+ /// The property is absent, or present but not a JSON string (nothing to validate).
+ Absent,
+
+ /// The property is a JSON string that parses (case-insensitively) to the enum.
+ Valid,
+
+ /// The property is a JSON string that does NOT parse to the enum (a typo'd value).
+ Invalid,
+}
+
+///
+/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
+/// six byte-identical private ReadEnum copies with one strict-capable home beside
+/// .
+/// Unknown-key handling is a deliberate non-goal. The TagConfig blob is a SHARED
+/// namespace — platform intent keys (FullName, alarm, isHistorized, …) coexist
+/// with per-driver address keys (region, dataType, …) and forward-compat keys the typed
+/// editors preserve. There is no single canonical schema, so these readers read the known fields and
+/// ignore the rest; they NEVER warn on unknown keys.
+///
+public static class TagConfigJson
+{
+ ///
+ /// Reads an enum-valued string field, distinguishing absent / valid / present-but-invalid so callers
+ /// can be lenient (fall back) while still reporting the invalid case. A property that is
+ /// absent or present-but-non-string yields (there is no enum
+ /// string to validate); a present JSON string that parses case-insensitively yields
+ /// ; a present JSON string that does not parse yields
+ /// .
+ ///
+ /// The enum type to parse.
+ /// The TagConfig root object.
+ /// The property name to read.
+ /// The parsed enum on ; otherwise default.
+ /// The read outcome.
+ public static JsonEnumRead TryReadEnum(JsonElement o, string name, out TEnum value)
+ where TEnum : struct, Enum
+ {
+ if (!o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.String)
+ {
+ value = default;
+ return JsonEnumRead.Absent;
+ }
+ if (Enum.TryParse(e.GetString(), ignoreCase: true, out value))
+ return JsonEnumRead.Valid;
+ value = default;
+ return JsonEnumRead.Invalid;
+ }
+
+ ///
+ /// Lenient enum read with today's exact semantics: absent OR invalid ⇒ ;
+ /// only a present, parseable string yields the parsed value. Bit-for-bit equivalent to the six
+ /// retired private ReadEnum copies.
+ ///
+ /// The enum type to parse.
+ /// The TagConfig root object.
+ /// The property name to read.
+ /// The value returned when the field is absent or invalid.
+ /// The parsed enum, or .
+ public static TEnum ReadEnumOrDefault(JsonElement o, string name, TEnum fallback)
+ where TEnum : struct, Enum
+ => TryReadEnum(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
+
+ ///
+ /// Reads the optional "writable" flag with AbCip semantics (the model): an explicit JSON
+ /// false ⇒ ; anything else (absent, true, or a non-bool token)
+ /// ⇒ .
+ ///
+ /// The TagConfig root object.
+ /// The value returned unless the field is an explicit JSON false.
+ /// only for an explicit false; otherwise .
+ public static bool ReadWritable(JsonElement o, bool defaultValue = true)
+ => o.TryGetProperty("writable", out var e) && e.ValueKind == JsonValueKind.False
+ ? false
+ : defaultValue;
+
+ ///
+ /// A human-readable warning for a present-but-invalid enum field (naming the field, the offending
+ /// value, and the valid enum members), or when the field is absent or valid.
+ ///
+ /// The enum type expected.
+ /// The TagConfig root object.
+ /// The property name to describe.
+ /// The warning text, or .
+ public static string? DescribeInvalidEnum(JsonElement o, string name)
+ where TEnum : struct, Enum
+ {
+ if (TryReadEnum(o, name, out _) != JsonEnumRead.Invalid) return null;
+ var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null;
+ var valid = string.Join(", ", Enum.GetNames());
+ return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
index 217cca88..96eacf2b 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
@@ -1,4 +1,4 @@
-using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -190,22 +190,8 @@ public static class EquipmentNodeWalker
///
/// The tag configuration JSON or string.
/// The extracted FullName value, or the raw if it can't be extracted.
- internal static string ExtractFullName(string tagConfig)
- {
- if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
- try
- {
- using var doc = JsonDocument.Parse(tagConfig);
- if (doc.RootElement.ValueKind == JsonValueKind.Object
- && doc.RootElement.TryGetProperty("FullName", out var fullName)
- && fullName.ValueKind == JsonValueKind.String)
- {
- return fullName.GetString() ?? tagConfig;
- }
- }
- catch (JsonException) { /* fall through */ }
- return tagConfig;
- }
+ internal static string ExtractFullName(string tagConfig) =>
+ TagConfigIntent.Parse(tagConfig).FullName;
///
/// Parse (stored as the enum
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj
index 44c4f648..6cd66876 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj
@@ -14,6 +14,8 @@
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
index 7a5597d7..a474d0ff 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -42,7 +43,7 @@ public static class AbCipEquipmentTagParser
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
- var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", AbCipDataType.DInt);
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
// foundation materialises a [1] OPC UA array node — so it must read as an array, not a
@@ -50,8 +51,9 @@ public static class AbCipEquipmentTagParser
// have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
- var writable = !root.TryGetProperty("writable", out var writableEl)
- || writableEl.ValueKind != JsonValueKind.False;
+ // Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the
+ // hand-rolled read it replaces.
+ var writable = TagConfigJson.ReadWritable(root);
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
@@ -83,9 +85,35 @@ public static class AbCipEquipmentTagParser
return (false, 1);
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
+ /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
+ /// or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ }
+ catch (JsonException)
+ {
+ warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj
index d911061a..9dbcccee 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj
@@ -5,5 +5,9 @@
enabletrue
-
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
index 22b7edbd..12469c9a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -38,7 +39,7 @@ public static class AbLegacyEquipmentTagParser
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
- var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", AbLegacyDataType.Int);
var deviceHostAddress = ReadString(root, "deviceHostAddress");
int? arrayLength = null;
if (IsArrayFlag(root))
@@ -47,9 +48,12 @@ public static class AbLegacyEquipmentTagParser
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
+ // "writable" defaults to true when absent (today's value) — makes read-only equipment tags
+ // authorable (UNDER-6).
+ var writable = TagConfigJson.ReadWritable(root);
def = new AbLegacyTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
- DataType: dataType, Writable: true, ArrayLength: arrayLength);
+ DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -57,9 +61,35 @@ public static class AbLegacyEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
+ /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
+ /// or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ }
+ catch (JsonException)
+ {
+ warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj
index d911061a..9dbcccee 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj
@@ -5,5 +5,9 @@
enabletrue
-
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
index 94bd1000..0e47e16e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -28,11 +29,17 @@ public static class FocasEquipmentTagParser
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
- var dataType = ReadEnum(root, "dataType", FocasDataType.Int32);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", FocasDataType.Int32);
var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? "";
def = new FocasTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
- DataType: dataType, Writable: true);
+ // FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client
+ // (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a
+ // writable node is a lie — a write that used to reach the backend and always fail with
+ // BadNotWritable now fails at the driver seam with the same family of Bad status (no
+ // successful operation changes). Any authored `writable:true` is ignored + warned by
+ // Inspect. Honour the key here once PMC writes ship in the wire client.
+ DataType: dataType, Writable: false);
return true;
}
catch (JsonException) { return false; }
@@ -40,9 +47,38 @@ public static class FocasEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType
+ /// (silently defaulted by the lenient runtime), on an authored writable:true (FOCAS writes
+ /// are unsupported — the tag is forced read-only), and on a structurally unparseable TagConfig.
+ /// Empty when clean or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
+ warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist.");
+ }
+ catch (JsonException)
+ {
+ warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj
index 3896f7bd..48b2f1bd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj
@@ -5,5 +5,10 @@
enabletrue
-
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index d36d6f0b..d2ba8789 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -68,7 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
- r => FocasEquipmentTagParser.TryParse(r, out var d) ? d : null);
+ ResolveEquipmentTagRef);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -234,6 +234,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
+ // Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME
+ // capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A
+ // reference whose address is unparseable, whose bound device is unknown, or whose address violates
+ // the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time,
+ // instead of a wrong-status read reaching the wire. The negative is cached by the resolver.
+ private FocasTagDefinition? ResolveEquipmentTagRef(string reference)
+ {
+ if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null;
+ var parsed = FocasAddress.TryParse(def.Address);
+ if (parsed is null) return null;
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null;
+ return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null;
+ }
+
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
// equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the
// address string on every ReadAsync / WriteAsync hot-path call.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
index 88e3b40a..a9ce79a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -29,9 +30,9 @@ public static class ModbusEquipmentTagParser
|| !addr.TryGetInt32(out var address)
|| address < 0 || address > ushort.MaxValue)
return false;
- var region = ReadEnum(root, "region", ModbusRegion.HoldingRegisters);
- var dataType = ReadEnum(root, "dataType", ModbusDataType.Int16);
- var byteOrder = ReadEnum(root, "byteOrder", ModbusByteOrder.BigEndian);
+ var region = TagConfigJson.ReadEnumOrDefault(root, "region", ModbusRegion.HoldingRegisters);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", ModbusDataType.Int16);
+ var byteOrder = TagConfigJson.ReadEnumOrDefault(root, "byteOrder", ModbusByteOrder.BigEndian);
var bitIndex = (byte)ReadInt(root, "bitIndex");
var stringLength = (ushort)ReadInt(root, "stringLength");
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
@@ -51,9 +52,12 @@ public static class ModbusEquipmentTagParser
var isArray = ReadBool(root, "isArray");
var arrayLength = ReadInt(root, "arrayLength");
int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
+ // "writable" defaults to true when absent (today's value) — makes read-only equipment tags
+ // authorable (UNDER-6). Node-level authz remains the effective write gate.
+ var writable = TagConfigJson.ReadWritable(root);
def = new ModbusTagDefinition(
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
- Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
+ Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
ArrayCount: arrayCount);
return true;
}
@@ -62,9 +66,44 @@ public static class ModbusEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection of an equipment-tag TagConfig blob (05/CONV-2). Returns
+ /// human-readable warnings for present-but-invalid enum values (region / dataType /
+ /// byteOrder — which the lenient runtime silently defaults) and for a structurally
+ /// unparseable TagConfig (which the runtime turns into a silent BadNodeIdUnknown). Empty when
+ /// the blob is clean or is not an equipment-tag TagConfig object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("Modbus TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ foreach (var w in new[]
+ {
+ TagConfigJson.DescribeInvalidEnum(root, "region"),
+ TagConfigJson.DescribeInvalidEnum(root, "dataType"),
+ TagConfigJson.DescribeInvalidEnum(root, "byteOrder"),
+ })
+ {
+ if (w is not null) warnings.Add(w);
+ }
+ }
+ catch (JsonException)
+ {
+ warnings.Add("Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj
index 98b2207c..0c2ad3ab 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj
@@ -12,6 +12,9 @@
and was designed for exactly this use — Admin can reference it without transport-layer deps. -->
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
index 38e7c5da..afd6683c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
@@ -29,19 +29,44 @@ public sealed class ModbusDriverProbe : IDriverProbe
///
public string DriverType => "Modbus";
+ /// The parsed probe target — host/port/unitId + the effective connection timeout, all read
+ /// from the SAME factory DTO shape (ModbusDriverConfigDto) the driver factory parses, so
+ /// timeoutMs binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).
+ internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
+
+ /// Parse the driver config JSON into a using the factory DTO shape.
+ /// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
+ /// timeout mirrors the factory (TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)); when the config
+ /// omits timeoutMs the caller's is used.
+ /// The driver config JSON (factory DTO shape).
+ /// The timeout used when the config omits timeoutMs.
+ /// The parsed target, or a null target with an error string.
+ internal static (ProbeTarget? Target, string? Error) TryParseProbeTarget(string configJson, TimeSpan fallbackTimeout)
+ {
+ ModbusDriverFactoryExtensions.ModbusDriverConfigDto? dto;
+ try { dto = JsonSerializer.Deserialize(configJson, _opts); }
+ catch (Exception ex) { return (null, $"Config JSON is invalid: {ex.Message}"); }
+ if (dto is null) return (null, "Config JSON deserialized to null.");
+
+ var port = dto.Port ?? 502;
+ if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
+ return (null, "Config has no host/port to probe.");
+
+ var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
+ return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
+ }
+
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
- ModbusDriverOptions? opts;
- try { opts = JsonSerializer.Deserialize(configJson, _opts); }
- catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
- if (opts is null) return new(false, "Config JSON deserialized to null.", null);
+ var (target, error) = TryParseProbeTarget(configJson, timeout);
+ if (target is not { } t) return new(false, error!, null);
- var (host, port) = ExtractTarget(opts);
- if (string.IsNullOrWhiteSpace(host) || port <= 0)
- return new(false, "Config has no host/port to probe.", null);
-
- var unitId = opts.UnitId;
+ var host = t.Host;
+ var port = t.Port;
+ var unitId = t.UnitId;
+ // Honour the config's timeoutMs (factory parity) — the caller's timeout is the fallback.
+ timeout = t.Timeout;
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -93,7 +118,4 @@ public sealed class ModbusDriverProbe : IDriverProbe
}
}
}
-
- private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
- => (opts.Host, opts.Port);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs
index 8c1844e0..320db73b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -31,7 +32,7 @@ public static class S7EquipmentTagParser
return false;
var address = addrEl.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
- var dataType = ReadEnum(root, "dataType", S7DataType.Int16);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", S7DataType.Int16);
var stringLength = ReadInt(root, "stringLength");
// Range-guard applies only to String tags: an S7 string can't exceed 254 chars, and a
// negative length is meaningless. For non-String types stringLength is irrelevant and any
@@ -42,11 +43,14 @@ public static class S7EquipmentTagParser
// that here so the driver's transient def agrees byte-for-byte with the materialised
// OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
var arrayCount = ReadArrayCount(root);
+ // "writable" defaults to true when absent (today's value); node-level authz still governs
+ // writes. Honouring the key makes read-only equipment tags authorable (UNDER-6).
+ var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
Name: reference,
Address: address,
DataType: dataType,
- Writable: true, // node-level authz governs writes
+ Writable: writable,
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
ArrayCount: arrayCount);
return true;
@@ -56,9 +60,35 @@ public static class S7EquipmentTagParser
catch (InvalidOperationException) { return false; }
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
+ /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
+ /// BadNodeIdUnknown at runtime). Empty when clean or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("S7 TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ }
+ catch (JsonException)
+ {
+ warnings.Add("S7 TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj
index 52900ddb..55d40545 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj
@@ -7,6 +7,11 @@
true
-
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
index 7fd03292..f4d620a4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -28,15 +29,18 @@ public static class TwinCATEquipmentTagParser
var symbolPath = symbol.GetString();
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
- var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt);
+ var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", TwinCATDataType.DInt);
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
// is true AND it is a positive JSON number, so a stale length behind a cleared
// isArray never produces an orphan array tag (Phase 4c).
var arrayLength = ReadArrayLength(root);
+ // "writable" defaults to true when absent (today's value) — makes read-only equipment tags
+ // authorable (UNDER-6).
+ var writable = TagConfigJson.ReadWritable(root);
def = new TwinCATTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
- DataType: dataType, Writable: true, ArrayLength: arrayLength);
+ DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -44,9 +48,35 @@ public static class TwinCATEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
- private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
- => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
- && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
+ ///
+ /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
+ /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
+ /// or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ }
+ catch (JsonException)
+ {
+ warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj
index d911061a..9dbcccee 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj
@@ -5,5 +5,9 @@
enabletrue
-
+
+
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
index 4d4d8f83..27328ab8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
@@ -17,6 +17,14 @@
+
+
+ Update(() => _m.Writable = e.Value is bool b && b))" />
+
+
+ Update(() => _m.Writable = e.Value is bool b && b))" />
+
+
+
@if (_showPicker)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
index fe607acc..8e8eea22 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
@@ -53,7 +53,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// DriverInstanceActor.AttributeValuePublished.FullReference
/// (src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97) — and that
/// FullReference is the FullName field extracted from Tag.TagConfig
-/// (see AddressSpaceComposer.ExtractTagFullName + EquipmentNodeWalker.ExtractFullName).
+/// (see Commons.Types.TagConfigIntent.Parse, the shared byte-parity FullName authority).
/// The UNS-path engine (Core.VirtualTags.VirtualTagEngine, keyed by a slash-joined
/// Enterprise/Site/Area/Line/Equipment/TagName) is dormant — it is NOT wired into the
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
@@ -179,8 +179,8 @@ public sealed class ScriptTagCatalog(IDbContextFactory d
///
/// Extracts the driver-side full reference from a Tag.TagConfig JSON blob — the
/// top-level FullName string every shipped driver stores. Mirrors
- /// EquipmentNodeWalker.ExtractFullName / AddressSpaceComposer.ExtractTagFullName
- /// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not
+ /// Commons.Types.TagConfigIntent.Parse(...).FullName (the shared byte-parity authority;
+ /// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not
/// a JSON object carrying a string FullName.
///
private static string ExtractFullNameFromTagConfig(string tagConfig)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs
index efb64254..4dd9044c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs
@@ -16,6 +16,10 @@ public sealed class AbCipTagConfigModel
/// Logix atomic type, or AbCipDataType.Structure for UDT-typed tags.
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
+ /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit
+ /// false ⇒ read-only. Preserved across load→save.
+ public bool? Writable { get; set; }
+
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -30,6 +34,7 @@ public sealed class AbCipTagConfigModel
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
+ Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_bag = o,
};
}
@@ -42,6 +47,7 @@ public sealed class AbCipTagConfigModel
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
+ TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
index cf0d2dd1..3f297905 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
@@ -16,6 +16,10 @@ public sealed class AbLegacyTagConfigModel
/// PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
+ /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit
+ /// false ⇒ read-only. Preserved across load→save.
+ public bool? Writable { get; set; }
+
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -30,6 +34,7 @@ public sealed class AbLegacyTagConfigModel
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
+ Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_bag = o,
};
}
@@ -42,6 +47,7 @@ public sealed class AbLegacyTagConfigModel
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
+ TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs
index 127da898..3ac57284 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs
@@ -25,6 +25,11 @@ public sealed class ModbusTagConfigModel
/// String length in characters for String tags.
public int StringLength { get; set; }
+ /// Optional writable flag (R2-11). null ⇒ the key is omitted (driver default true);
+ /// an explicit false makes the equipment tag read-only. Preserved across load→save so an absent
+ /// key stays absent.
+ public bool? Writable { get; set; }
+
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -42,6 +47,7 @@ public sealed class ModbusTagConfigModel
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
+ Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_bag = o,
};
}
@@ -57,6 +63,7 @@ public sealed class ModbusTagConfigModel
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
TagConfigJson.Set(_bag, "stringLength", StringLength);
+ TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
index 82a6348b..86deb8ee 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs
@@ -7,9 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// stable nsu=…;s=… or plain ns=2;s=… NodeId the driver reads/writes/subscribes against).
/// Preserves unrecognised JSON keys across a load→save.
///
-/// The FullName key is intentionally PascalCase: the deploy-time composer + node walker
-/// (AddressSpaceComposer.ExtractTagFullName, EquipmentNodeWalker) read it via a
-/// case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact
+/// The FullName key is intentionally PascalCase: the shared deploy-time parser
+/// (Commons.Types.TagConfigIntent.Parse, consumed by the composer, artifact decoder, draft
+/// gate, and node walker) reads it via a case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact
/// casing. The driver-agnostic server-side HistoryRead intent keys (isHistorized /
/// historianTagname) are NOT modelled here — they are owned by the TagModal-merge seam
/// () and survive a load→save of this model as preserved unknown keys.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
index 7d561a71..6cd40543 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
@@ -16,6 +16,10 @@ public sealed class S7TagConfigModel
/// For DataType = String: S7-string max length (max 254).
public int StringLength { get; set; }
+ /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit
+ /// false ⇒ read-only. Preserved across load→save.
+ public bool? Writable { get; set; }
+
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -30,6 +34,7 @@ public sealed class S7TagConfigModel
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
+ Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_bag = o,
};
}
@@ -42,6 +47,7 @@ public sealed class S7TagConfigModel
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "stringLength", StringLength);
+ TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs
index 69d6ac4b..01624144 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs
@@ -48,6 +48,14 @@ public static class TagConfigJson
public static bool GetBool(JsonObject o, string name, bool fallback = false)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var b) ? b : fallback;
+ /// Reads a bool value, or null when the property is absent/null/non-boolean — so an
+ /// absent key stays absent through a load→save (distinguishes "not set" from an explicit false).
+ /// The JSON object to read from.
+ /// The property name to read.
+ /// The bool value, or null when absent/null/non-boolean.
+ public static bool? GetBoolNullable(JsonObject o, string name)
+ => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var b) ? b : null;
+
/// Reads an enum by its serialised name, or if absent/unparseable.
/// The enum type to parse.
/// The JSON object to read from.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs
index b4756ed4..ec9961ea 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs
@@ -16,6 +16,10 @@ public sealed class TwinCATTagConfigModel
/// TwinCAT / IEC 61131-3 atomic data type.
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
+ /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit
+ /// false ⇒ read-only. Preserved across load→save.
+ public bool? Writable { get; set; }
+
private JsonObject _bag = new();
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -30,6 +34,7 @@ public sealed class TwinCATTagConfigModel
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
+ Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_bag = o,
};
}
@@ -42,6 +47,7 @@ public sealed class TwinCATTagConfigModel
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
+ TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
index 7448ed8d..645ee64e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs
@@ -25,31 +25,37 @@ public sealed class AdminOperationsActor : ReceiveActor
private readonly IDbContextFactory _dbFactory;
private readonly IActorRef _coordinator;
private readonly IReadOnlyDictionary _probesByType;
+ private readonly TagConfigValidationMode _tagConfigValidationMode;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// Creates actor props for the admin operations actor.
/// Factory for creating config database contexts.
/// Reference to the deployment coordinator actor.
/// Driver probes registered in DI; keyed by DriverType (case-insensitive).
+ /// Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).
/// Props configured to create an AdminOperationsActor.
public static Props Props(
IDbContextFactory dbFactory,
IActorRef coordinator,
- IEnumerable probes) =>
- Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes));
+ IEnumerable probes,
+ TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn) =>
+ Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode));
/// Initializes a new instance of the AdminOperationsActor.
/// Factory for creating config database contexts.
/// Reference to the deployment coordinator actor.
/// Driver probes registered in DI; keyed by DriverType (case-insensitive).
+ /// Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).
public AdminOperationsActor(
IDbContextFactory dbFactory,
IActorRef coordinator,
- IEnumerable probes)
+ IEnumerable probes,
+ TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn)
{
_dbFactory = dbFactory;
_coordinator = coordinator;
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
+ _tagConfigValidationMode = tagConfigValidationMode;
ReceiveAsync(HandleStartDeploymentAsync);
ReceiveAsync(HandleTestDriverConnectAsync);
@@ -202,6 +208,31 @@ public sealed class AdminOperationsActor : ReceiveActor
errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes));
}
+ // R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The
+ // driver-typed EquipmentTagConfigInspector surfaces present-but-invalid enum values (which the
+ // lenient runtime silently defaults) + structurally-unparseable TagConfig. In Warn mode
+ // (default) these are non-blocking (logged + appended to the accepted result Message); in Error
+ // mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
+ // Running servers are untouched either way — the gate only sees re-deploys.
+ var driverTypeById = draft.DriverInstances
+ .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
+ var tagConfigWarnings = new List<(string TagId, string Text)>();
+ foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null))
+ {
+ if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue;
+ foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
+ tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}"));
+ }
+ if (tagConfigWarnings.Count > 0)
+ {
+ if (_tagConfigValidationMode == TagConfigValidationMode.Error)
+ errors.AddRange(tagConfigWarnings.Select(x => new ValidationError("TagConfigInvalid", x.Text, x.TagId)));
+ else
+ foreach (var x in tagConfigWarnings)
+ _log.Warning("StartDeployment: TagConfig warning (Warn mode, non-blocking): {Warning}", x.Text);
+ }
+
if (errors.Count > 0)
{
var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"));
@@ -277,11 +308,18 @@ public sealed class AdminOperationsActor : ReceiveActor
_coordinator.Tell(new DispatchDeployment(deploymentId, revHash, msg.CorrelationId));
+ // Accepted message = the compile-cost advisory (if any) + any Warn-mode TagConfig warnings, so
+ // the operator sees the non-blocking strictness findings on a successful deploy.
+ var messageParts = new List();
+ if (advisory is not null) messageParts.Add(advisory);
+ messageParts.AddRange(tagConfigWarnings.Select(x => x.Text));
+ var acceptedMessage = messageParts.Count > 0 ? string.Join("; ", messageParts) : null;
+
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.Accepted,
deploymentId,
revHash,
- Message: advisory,
+ Message: acceptedMessage,
msg.CorrelationId));
}
catch (Exception ex)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
new file mode 100644
index 00000000..852ec7c9
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+using ZB.MOM.WW.OtOpcUa.Driver.S7;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
+
+///
+/// Deploy-time TagConfig inspection dispatched by DriverType (R2-11, 05/CONV-2). Mirrors the
+/// AdminUI TagConfigValidator shape: a case-insensitive map from the canonical driver-type string
+/// to that driver's lightweight Contracts parser Inspect(), returning human-readable
+/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus
+/// structurally-unparseable TagConfig). Unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in
+/// the AdminUI validator. Never throws.
+///
+public static class EquipmentTagConfigInspector
+{
+ private static readonly IReadOnlyDictionary>> Inspectors =
+ new Dictionary>>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Modbus"] = ModbusEquipmentTagParser.Inspect,
+ ["S7"] = S7EquipmentTagParser.Inspect,
+ ["AbCip"] = AbCipEquipmentTagParser.Inspect,
+ ["AbLegacy"] = AbLegacyEquipmentTagParser.Inspect,
+ ["TwinCat"] = TwinCATEquipmentTagParser.Inspect,
+ ["Focas"] = FocasEquipmentTagParser.Inspect,
+ };
+
+ /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings,
+ /// or an empty list when the driver type is unmapped (Galaxy/OpcUaClient) or the inputs are null.
+ /// The bound driver's DriverType (canonical string, case-insensitive).
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when the driver is unmapped or clean.
+ public static IReadOnlyList Inspect(string? driverType, string? tagConfig)
+ {
+ if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty();
+ return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty();
+ }
+
+ /// Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only).
+ /// The bound driver's DriverType.
+ /// when an inspector is registered for the driver type.
+ public static bool IsMapped(string? driverType) =>
+ !string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs
new file mode 100644
index 00000000..e53e5924
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
+
+///
+/// How the deploy gate treats TagConfig strictness inspection results (R2-11, 05/CONV-2). Sourced from
+/// Deployment:TagConfigValidationMode in appsettings; defaults to so existing
+/// deployments are never blocked by a previously-tolerated typo.
+///
+public enum TagConfigValidationMode
+{
+ /// Non-blocking: inspection warnings are logged and appended to the accepted
+ /// StartDeploymentResult.Message, but the deploy still succeeds. The default.
+ Warn,
+
+ /// Blocking: inspection warnings fold into the draft-gate reject list, so a config with a
+ /// typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment
+ /// once the fleet deploys warning-clean.
+ Error,
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs
index e9276742..20753d13 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster.Hosting;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -61,7 +62,12 @@ public static class ServiceCollectionExtensions
var dbFactory = resolver.GetService>();
var coordinator = registry.Get();
var probes = resolver.GetService>() ?? Enumerable.Empty();
- return AdminOperationsActor.Props(dbFactory, coordinator, probes);
+ // R2-11 (05/CONV-2): deploy-gate TagConfig strictness mode from appsettings
+ // (Deployment:TagConfigValidationMode = Warn default | Error opt-in).
+ var mode = Enum.TryParse(
+ resolver.GetService()?["Deployment:TagConfigValidationMode"],
+ ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn;
+ return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode);
},
singletonOptions);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index 87fe34cc..4c0ed37c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -26,6 +26,14 @@
+
+
+
+
+
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
index 42138913..d3c19359 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
@@ -43,5 +43,9 @@
"DrainIntervalSeconds": 5,
"Capacity": 1000000,
"DeadLetterRetentionDays": 30
+ },
+ "Deployment": {
+ "_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.",
+ "TagConfigValidationMode": "Warn"
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index 3fba3d99..b3980942 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -647,7 +647,7 @@ public sealed class AddressSpaceApplier
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
- // AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
+ // TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index 4ef942e9..eff3d232 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -1,5 +1,4 @@
using System.Diagnostics;
-using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -66,11 +65,11 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
/// (both null ⇒ driver-less / no device), copied straight from the Equipment row.
/// is the device's connection host (e.g. "10.0.0.5:8193") resolved from the
/// bound Device's schemaless DeviceConfig JSON via
-/// — null when there is no device, no
-/// HostAddress in its config, or the host cannot be parsed. These three let a later task graft a
-/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
+/// — null when there
+/// is no device, no HostAddress in its config, or the host cannot be parsed. These three let a later
+/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
-/// the artifact-decode sides (single source of truth: );
+/// the artifact-decode sides (single source of truth: );
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
/// (trim + lower-case) so the two compare equal.
/// Address-space-rebuild interaction (accepted trade-off). These three fields participate in
@@ -350,8 +349,8 @@ public static class AddressSpaceComposer
var resolvedScripts = scripts ?? Array.Empty