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 @@ enable true - + + + + 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 @@ enable true - + + + + 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 @@ enable true - + + + + 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 @@ enable true - + + + + 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 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor index 2bdc9e6d..8f770103 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor @@ -17,6 +17,14 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor index 1a0a5a00..5eec1f55 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor @@ -17,6 +17,12 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor index b59ba9ac..b9449866 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor @@ -25,6 +25,14 @@
+
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor index 9f1d8611..aba5047a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor @@ -17,6 +17,14 @@
+
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor index 7d7f9c80..143fdee0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor @@ -17,6 +17,14 @@ +
+
+ + +
+
@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