Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s

Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 11:29:32 -04:00
74 changed files with 2228 additions and 682 deletions
@@ -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.
@@ -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"] }
]
}
+2
View File
@@ -176,6 +176,8 @@ what the unit leg cannot; (c) migrate the 3 xunit v2 holdouts once Akka ships an
| **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-10** | 03/U10=04/U-5 (Medium/Low, action #10) | `r2/10-resilience-observability` (off `46fedda3`) | `205d3980``<docs>` (11 task commits) | **Code-complete, offline-verified.** The resilience status tracker now has a production reader, mirroring the driver-health flow. Core truthfulness: `RecordBreakerClose` + derived `IsBreakerOpen` (breaker-open state derivable), `OnClosed` records the close, and `RecordSuccess` wired onto the invoker success paths (`ConsecutiveFailures` is a true consecutive counter, not stuck-forever). Transport: `DriverResilienceStatusChanged` Commons contract → periodic 5 s full-snapshot publisher `DriverResilienceStatusPublisherService` (driver nodes, registered in `AddOtOpcUaDriverFactories` with wiring guard `ResilienceStatusReaderRegistrationTests` — closes the built-but-never-wired class) → `driver-resilience-status` DPS topic → per-admin-node `DriverResilienceStatusBridge``IDriverResilienceStatusStore` → resilience chips (breaker-OPEN / consecutive-failures / in-flight / staleness) on `DriverStatusPanel.razor`, read in-process (self-HubConnection ban honored). Core.Tests resilience 31/31 (incl. deterministic breaker-close via a controllable TimeProvider), Host.IntegrationTests publisher+guard 6/6, AdminUI.Tests store 5/5; full-solution build clean. **Deferred (T10):** docker-dev live-`/run` — S7 dead-endpoint breaker-open observed on BOTH centrals + recovery-clear + staleness-dim (also the first live sighting of the #10 `circuit-breaker OPENED` log line, closing FOLLOWUP-10's behavioral gate). |
| **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
+47
View File
@@ -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
+10
View File
@@ -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
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// SINGLE SOURCE OF TRUTH for extracting + normalizing a <c>Device</c>'s connection host from its
/// schemaless <c>DeviceConfig</c> JSON. Shared by the live-compose seam (<c>AddressSpaceComposer</c>),
/// the artifact-decode seam (<c>DeploymentArtifact</c>), and the FixedTree-partition path
/// (<c>DriverHostActor</c>) so an <c>EquipmentNode.DeviceHost</c> and a driver-discovered device-host
/// folder segment for the same device compare byte-equal. Ported verbatim from the historical
/// <c>AddressSpaceComposer.TryExtractDeviceHost</c> / <c>NormalizeDeviceHost</c>.
/// </summary>
public static class DeviceConfigIntent
{
/// <summary>
/// Extract a <c>Device</c>'s connection host from its schemaless <c>DeviceConfig</c> JSON: the
/// top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>). Returns
/// <see langword="null"/> when the config is blank, not a JSON object, has no string
/// <c>HostAddress</c>, or the value is blank/whitespace. Never throws. The returned host is
/// deterministically normalized via <see cref="NormalizeHost"/> (trim + lower-case).
/// </summary>
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
/// <returns>The normalized device host, or <see langword="null"/> when absent/blank/unparseable.</returns>
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; }
}
/// <summary>
/// The single normalization authority for a device host: trims surrounding whitespace and
/// lower-cases (invariant). Idempotent (an already-normalized value is unchanged).
/// </summary>
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
/// <returns>The normalized host (trimmed + lower-cased).</returns>
public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant();
}
@@ -0,0 +1,129 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// The cross-driver platform intents parsed from a Tag's schemaless <c>TagConfig</c> JSON.
/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
/// (<c>AddressSpaceComposer</c>), the artifact-decode seam (<c>DeploymentArtifact</c>), the draft
/// gate (<c>DraftValidator</c>), and the walker (<c>EquipmentNodeWalker</c>). One
/// <see cref="JsonDocument.Parse(string)"/> per blob. Never throws.
/// <para>Two deliberate variations are carried on the record so every seam can be served from one
/// parse:</para>
/// <list type="bullet">
/// <item><description><see cref="FullName"/> is the driver-side wire reference: the top-level
/// <c>FullName</c> string when present, else the RAW blob (an equipment tag's TagConfig JSON
/// <em>is</em> its wire reference for the six protocol drivers). <c>Parse(null)</c> ⇒
/// <c>FullName == ""</c>.</description></item>
/// <item><description><see cref="ExplicitFullName"/> is the <c>FullName</c> property ONLY when
/// present-and-string, else <see langword="null"/> — the DraftValidator Galaxy-rule semantics
/// (it wants the explicit reference, not the raw-blob fallback).</description></item>
/// </list>
/// </summary>
/// <param name="FullName">Top-level <c>FullName</c> string, else the raw blob (wire-reference fallback).</param>
/// <param name="ExplicitFullName">The <c>FullName</c> property when present-and-string, else <see langword="null"/>.</param>
/// <param name="Alarm">The optional native-alarm intent parsed from the <c>alarm</c> object; <see langword="null"/> ⇒ plain value variable.</param>
/// <param name="IsHistorized"><c>isHistorized</c> — true only when the value is a bool <c>true</c>.</param>
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
/// <param name="IsArray"><c>isArray</c> bool.</param>
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
public sealed record TagConfigIntent(
string FullName,
string? ExplicitFullName,
TagAlarmIntent? Alarm,
bool IsHistorized,
string? HistorianTagname,
bool IsArray,
uint? ArrayLength)
{
/// <summary>
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
/// <see cref="FullName"/> = the raw blob (or "" for null). Semantics are ported verbatim from the
/// historical <c>AddressSpaceComposer.Extract*</c> statics (the canonical copy).
/// </summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed intents; never <see langword="null"/>.</returns>
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);
}
}
/// <summary>The optional native-alarm intent parsed from a tag's <c>TagConfig.alarm</c> object.</summary>
/// <param name="AlarmType">OPC UA Part 9 subtype string; default <c>"AlarmCondition"</c>.</param>
/// <param name="Severity">1..1000 severity; default <c>500</c>.</param>
/// <param name="HistorizeToAveva">Per-condition durable-write opt-out (<c>bool?</c>; absent ⇒ <see langword="null"/> ⇒ historize).</param>
public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
@@ -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<ValidationError> errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
@@ -27,6 +27,9 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,100 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>The outcome of reading an enum-valued field from a TagConfig JSON object.</summary>
public enum JsonEnumRead
{
/// <summary>The property is absent, or present but not a JSON string (nothing to validate).</summary>
Absent,
/// <summary>The property is a JSON string that parses (case-insensitively) to the enum.</summary>
Valid,
/// <summary>The property is a JSON string that does NOT parse to the enum (a typo'd value).</summary>
Invalid,
}
/// <summary>
/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
/// namespace — platform intent keys (<c>FullName</c>, <c>alarm</c>, <c>isHistorized</c>, …) coexist
/// with per-driver address keys (<c>region</c>, <c>dataType</c>, …) 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.</para>
/// </summary>
public static class TagConfigJson
{
/// <summary>
/// Reads an enum-valued string field, distinguishing absent / valid / present-but-invalid so callers
/// can be lenient (fall back) while still <em>reporting</em> the invalid case. A property that is
/// absent or present-but-non-string yields <see cref="JsonEnumRead.Absent"/> (there is no enum
/// string to validate); a present JSON string that parses case-insensitively yields
/// <see cref="JsonEnumRead.Valid"/>; a present JSON string that does not parse yields
/// <see cref="JsonEnumRead.Invalid"/>.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to read.</param>
/// <param name="value">The parsed enum on <see cref="JsonEnumRead.Valid"/>; otherwise <c>default</c>.</param>
/// <returns>The read outcome.</returns>
public static JsonEnumRead TryReadEnum<TEnum>(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;
}
/// <summary>
/// Lenient enum read with today's exact semantics: absent OR invalid ⇒ <paramref name="fallback"/>;
/// only a present, parseable string yields the parsed value. Bit-for-bit equivalent to the six
/// retired private <c>ReadEnum</c> copies.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value returned when the field is absent or invalid.</param>
/// <returns>The parsed enum, or <paramref name="fallback"/>.</returns>
public static TEnum ReadEnumOrDefault<TEnum>(JsonElement o, string name, TEnum fallback)
where TEnum : struct, Enum
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
/// <summary>
/// Reads the optional <c>"writable"</c> flag with AbCip semantics (the model): an explicit JSON
/// <c>false</c> ⇒ <see langword="false"/>; anything else (absent, <c>true</c>, or a non-bool token)
/// ⇒ <paramref name="defaultValue"/>.
/// </summary>
/// <param name="o">The TagConfig root object.</param>
/// <param name="defaultValue">The value returned unless the field is an explicit JSON <c>false</c>.</param>
/// <returns><see langword="false"/> only for an explicit <c>false</c>; otherwise <paramref name="defaultValue"/>.</returns>
public static bool ReadWritable(JsonElement o, bool defaultValue = true)
=> o.TryGetProperty("writable", out var e) && e.ValueKind == JsonValueKind.False
? false
: defaultValue;
/// <summary>
/// A human-readable warning for a present-but-invalid enum field (naming the field, the offending
/// value, and the valid enum members), or <see langword="null"/> when the field is absent or valid.
/// </summary>
/// <typeparam name="TEnum">The enum type expected.</typeparam>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to describe.</param>
/// <returns>The warning text, or <see langword="null"/>.</returns>
public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
where TEnum : struct, Enum
{
if (TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid) return null;
var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null;
var valid = string.Join(", ", Enum.GetNames<TEnum>());
return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
}
}
@@ -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
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The extracted <c>FullName</c> value, or the raw <paramref name="tagConfig"/> if it can't be extracted.</returns>
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;
/// <summary>
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
@@ -14,6 +14,8 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<!-- R2-11: EquipmentNodeWalker.ExtractFullName delegates to the shared TagConfigIntent.Parse. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<AbCipDataType>(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
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<AbLegacyDataType>(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
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<FocasDataType>(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;
@@ -5,5 +5,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers beside EquipmentTagRefResolver; no
dependency-closure growth for lightweight AdminUI/Cli consumers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -68,7 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
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.
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob (05/CONV-2). Returns
/// human-readable warnings for present-but-invalid enum values (<c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> — which the lenient runtime silently defaults) and for a structurally
/// unparseable TagConfig (which the runtime turns into a silent <c>BadNodeIdUnknown</c>). Empty when
/// the blob is clean or is not an equipment-tag TagConfig object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<ModbusRegion>(root, "region"),
TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType"),
TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(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
@@ -12,6 +12,9 @@
and was designed for exactly this use — Admin can reference it without transport-layer deps. -->
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
<!-- R2-11 (05/CONV-2): shared TagConfigJson field readers live in the zero-dependency
Core.Abstractions leaf beside EquipmentTagRefResolver. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -29,19 +29,44 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> 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 (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns>
internal static (ProbeTarget? Target, string? Error) TryParseProbeTarget(string configJson, TimeSpan fallbackTimeout)
{
ModbusDriverFactoryExtensions.ModbusDriverConfigDto? dto;
try { dto = JsonSerializer.Deserialize<ModbusDriverFactoryExtensions.ModbusDriverConfigDto>(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);
}
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
ModbusDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(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);
}
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
/// <c>BadNodeIdUnknown</c> at runtime). Empty when clean or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<S7DataType>(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
@@ -7,6 +7,11 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers beside EquipmentTagRefResolver; adds no
dependency-closure growth for lightweight AdminUI/Cli consumers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<TwinCATDataType>(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
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -17,6 +17,14 @@
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbCipDataType.DInt)))">
@foreach (var v in Enum.GetValues<AbCipDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="abcip-writable"
checked="@(_m.Writable ?? true)"
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
<label class="form-check-label small" for="abcip-writable">Writable (uncheck for a read-only tag)</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -17,6 +17,14 @@
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbLegacyDataType.Int)))">
@foreach (var v in Enum.GetValues<AbLegacyDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="ablegacy-writable"
checked="@(_m.Writable ?? true)"
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
<label class="form-check-label small" for="ablegacy-writable">Writable (uncheck for a read-only tag)</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -17,6 +17,12 @@
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="focas-writable" disabled checked="false" />
<label class="form-check-label small text-muted" for="focas-writable">Writable — FOCAS writes are unsupported; tags are read-only.</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -25,6 +25,14 @@
<div class="col-12 mt-1">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="modbus-writable"
checked="@(_m.Writable ?? true)"
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
<label class="form-check-label small" for="modbus-writable">Writable (uncheck for a read-only tag)</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -17,6 +17,14 @@
</select></div>
<div class="col-md-3"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="s7-writable"
checked="@(_m.Writable ?? true)"
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
<label class="form-check-label small" for="s7-writable">Writable (uncheck for a read-only tag)</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -17,6 +17,14 @@
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, TwinCATDataType.DInt)))">
@foreach (var v in Enum.GetValues<TwinCATDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="twincat-writable"
checked="@(_m.Writable ?? true)"
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
<label class="form-check-label small" for="twincat-writable">Writable (uncheck for a read-only tag)</label>
</div>
</div>
</div>
@if (_showPicker)
@@ -53,7 +53,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
/// (see <c>AddressSpaceComposer.ExtractTagFullName</c> + <c>EquipmentNodeWalker.ExtractFullName</c>).
/// (see <c>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) 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<OtOpcUaConfigDbContext> d
/// <summary>
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
/// <c>EquipmentNodeWalker.ExtractFullName</c> / <c>AddressSpaceComposer.ExtractTagFullName</c>
/// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not
/// <c>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (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 <c>FullName</c>.
/// </summary>
private static string ExtractFullNameFromTagConfig(string tagConfig)
@@ -16,6 +16,10 @@ public sealed class AbCipTagConfigModel
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
public bool? Writable { get; set; }
private JsonObject _bag = new();
/// <summary>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);
}
@@ -16,6 +16,10 @@ public sealed class AbLegacyTagConfigModel
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
public bool? Writable { get; set; }
private JsonObject _bag = new();
/// <summary>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);
}
@@ -25,6 +25,11 @@ public sealed class ModbusTagConfigModel
/// <summary>String length in characters for String tags.</summary>
public int StringLength { get; set; }
/// <summary>Optional writable flag (R2-11). <c>null</c> ⇒ the key is omitted (driver default true);
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
/// key stays absent.</summary>
public bool? Writable { get; set; }
private JsonObject _bag = new();
/// <summary>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);
}
@@ -7,9 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
/// Preserves unrecognised JSON keys across a load→save.</summary>
/// <remarks>
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
/// (<c>AddressSpaceComposer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// The <c>FullName</c> key is intentionally PascalCase: the shared deploy-time parser
/// (<c>Commons.Types.TagConfigIntent.Parse</c>, consumed by the composer, artifact decoder, draft
/// gate, and node walker) reads it via a case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
@@ -16,6 +16,10 @@ public sealed class S7TagConfigModel
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
public int StringLength { get; set; }
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
public bool? Writable { get; set; }
private JsonObject _bag = new();
/// <summary>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);
}
@@ -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<bool>(out var b) ? b : fallback;
/// <summary>Reads a bool value, or <c>null</c> 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).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The bool value, or <c>null</c> when absent/null/non-boolean.</returns>
public static bool? GetBoolNullable(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : null;
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
@@ -16,6 +16,10 @@ public sealed class TwinCATTagConfigModel
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
public bool? Writable { get; set; }
private JsonObject _bag = new();
/// <summary>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);
}
@@ -25,31 +25,37 @@ public sealed class AdminOperationsActor : ReceiveActor
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly IActorRef _coordinator;
private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType;
private readonly TagConfigValidationMode _tagConfigValidationMode;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for the admin operations actor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
/// <returns>Props configured to create an AdminOperationsActor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator,
IEnumerable<IDriverProbe> probes) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes));
IEnumerable<IDriverProbe> probes,
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode));
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
public AdminOperationsActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator,
IEnumerable<IDriverProbe> probes)
IEnumerable<IDriverProbe> probes,
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn)
{
_dbFactory = dbFactory;
_coordinator = coordinator;
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
_tagConfigValidationMode = tagConfigValidationMode;
ReceiveAsync<StartDeployment>(HandleStartDeploymentAsync);
ReceiveAsync<TestDriverConnect>(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<string>();
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)
@@ -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;
/// <summary>
/// Deploy-time TagConfig inspection dispatched by <c>DriverType</c> (R2-11, 05/CONV-2). Mirrors the
/// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string
/// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, 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.
/// </summary>
public static class EquipmentTagConfigInspector
{
private static readonly IReadOnlyDictionary<string, Func<string, IReadOnlyList<string>>> Inspectors =
new Dictionary<string, Func<string, IReadOnlyList<string>>>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = ModbusEquipmentTagParser.Inspect,
["S7"] = S7EquipmentTagParser.Inspect,
["AbCip"] = AbCipEquipmentTagParser.Inspect,
["AbLegacy"] = AbLegacyEquipmentTagParser.Inspect,
["TwinCat"] = TwinCATEquipmentTagParser.Inspect,
["Focas"] = FocasEquipmentTagParser.Inspect,
};
/// <summary>Inspects a tag's <c>TagConfig</c> 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.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c> (canonical string, case-insensitive).</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <returns>The warnings; empty when the driver is unmapped or clean.</returns>
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig)
{
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
}
/// <summary>Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only).</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>
public static bool IsMapped(string? driverType) =>
!string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType);
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// <summary>
/// How the deploy gate treats TagConfig strictness inspection results (R2-11, 05/CONV-2). Sourced from
/// <c>Deployment:TagConfigValidationMode</c> in appsettings; defaults to <see cref="Warn"/> so existing
/// deployments are never blocked by a previously-tolerated typo.
/// </summary>
public enum TagConfigValidationMode
{
/// <summary>Non-blocking: inspection warnings are logged and appended to the accepted
/// <c>StartDeploymentResult.Message</c>, but the deploy still succeeds. The default.</summary>
Warn,
/// <summary>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.</summary>
Error,
}
@@ -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<IDbContextFactory<OtOpcUaConfigDbContext>>();
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
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<TagConfigValidationMode>(
resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"],
ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn;
return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode);
},
singletonOptions);
@@ -26,6 +26,14 @@
<!-- Roslyn-free; gives HandleStartDeploymentAsync PassthroughScript.TryMatch to exclude
mirror scripts (which compile to ~nothing) from the deploy compile-cost advisory. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
<!-- R2-11 (05/CONV-2): the driver-typed EquipmentTagConfigInspector dispatches to each driver's
lightweight Contracts parser Inspect(), the same dependency-light consumption AdminUI does. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts\ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -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"
}
}
@@ -689,7 +689,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) =>
@@ -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 <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> — <c>null</c> when there is no device, no
/// <c>HostAddress</c> 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
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <c>null</c> when there
/// is no device, no <c>HostAddress</c> 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: <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>);
/// the artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
/// (trim + lower-case) so the two compare equal.</para>
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
@@ -350,8 +349,8 @@ public static class AddressSpaceComposer
var resolvedScripts = scripts ?? Array.Empty<Script>();
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
@@ -360,7 +359,7 @@ public static class AddressSpaceComposer
foreach (var d in devices ?? Array.Empty<Device>())
{
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
}
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
@@ -415,8 +414,11 @@ public static class AddressSpaceComposer
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t =>
{
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
var intent = TagConfigIntent.Parse(t.TagConfig);
return new EquipmentTagPlan(
TagId: t.TagId,
EquipmentId: t.EquipmentId!,
@@ -424,13 +426,13 @@ public static class AddressSpaceComposer
FolderPath: t.FolderPath ?? string.Empty,
Name: t.Name,
DataType: t.DataType,
FullName: ExtractTagFullName(t.TagConfig),
FullName: intent.FullName,
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: ExtractTagAlarm(t.TagConfig),
IsHistorized: isHistorized,
HistorianTagname: historianTagname,
IsArray: isArray,
ArrayLength: arrayLength);
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength);
})
.ToList();
@@ -523,165 +525,4 @@ public static class AddressSpaceComposer
};
}
/// <summary>
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
/// </summary>
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
private static string ExtractTagFullName(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 to raw blob */ }
return tagConfig;
}
/// <summary>
/// Extract a <see cref="Device"/>'s connection host from its schemaless <c>DeviceConfig</c> JSON:
/// the top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>) — the same value a
/// FOCAS driver emits as its discovered device-host folder segment. Returns <c>null</c> when the
/// config is blank, not a JSON object, has no string <c>HostAddress</c>, or the value is
/// blank/whitespace. Never throws.
/// <para>The returned host is deterministically normalized — trimmed and lower-cased — so the
/// live-edit composer side and the artifact-decode side (<c>DeploymentArtifact</c>) agree
/// byte-for-byte. This method is the SINGLE SOURCE OF TRUTH for that normalization: the later
/// FixedTree-partition task MUST normalize the driver-discovered device-host folder segment the
/// same way (call this, or apply the identical trim + lower-case) before comparing the two.</para>
/// </summary>
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
/// <returns>The normalized device host, or <c>null</c> when absent/blank/unparseable.</returns>
public static string? TryExtractDeviceHost(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;
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
return NormalizeDeviceHost(raw);
}
catch (JsonException) { return null; }
}
/// <summary>
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
/// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged).
/// </summary>
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
/// <returns>The normalized host (trimmed + lower-cased).</returns>
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.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 EquipmentTagAlarmInfo(type, sev, historize);
}
catch (JsonException) { return null; }
}
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
&& hEl.GetBoolean();
string? tagname = null;
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
&& nEl.ValueKind == JsonValueKind.String)
{
var raw = nEl.GetString();
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
}
return (isHistorized, tagname);
}
catch (JsonException) { return (false, null); }
}
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
/// <c>arrayLength</c> uint. The length is honoured ONLY when <c>isArray</c> is true AND the prop is a
/// JSON number that fits <c>uint</c> (else <c>null</c> ⇒ unbounded 1-D array,
/// <c>ArrayDimensions=[0]</c> at materialisation). Mirrors
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
/// tolerance. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed array flag and optional array length.</returns>
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
&& aEl.GetBoolean();
uint? arrayLength = null;
if (isArray
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
&& lEl.ValueKind == JsonValueKind.Number
&& lEl.TryGetUInt32(out var len))
{
arrayLength = len;
}
return (isArray, arrayLength);
}
catch (JsonException) { return (false, null); }
}
}
@@ -458,8 +458,10 @@ public static class DeploymentArtifact
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
if (!equipmentNamespaces.Contains(nsId)) continue;
var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig);
var (isArray, arrayLength) = ExtractTagArray(tagConfig);
// Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
// (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
// consumes, so the artifact-decode plan stays byte-equal with the composer's.
var intent = TagConfigIntent.Parse(tagConfig);
result.Add(new EquipmentTagPlan(
TagId: tagId!,
EquipmentId: equipmentId!,
@@ -467,13 +469,13 @@ public static class DeploymentArtifact
FolderPath: folder ?? string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
FullName: ExtractTagFullName(tagConfig),
FullName: intent.FullName,
Writable: writable,
Alarm: ExtractTagAlarm(tagConfig),
IsHistorized: isHistorized,
HistorianTagname: historianTagname,
IsArray: isArray,
ArrayLength: arrayLength));
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength));
}
result.Sort((a, b) =>
@@ -665,113 +667,6 @@ public static class DeploymentArtifact
return result;
}
/// <summary>
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
/// field). The artifact-decode mirror of <c>AddressSpaceComposer.ExtractTagFullName</c> /
/// <c>EquipmentNodeWalker.ExtractFullName</c> — replicated because Runtime does not reference
/// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
/// </summary>
private static string ExtractTagFullName(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty;
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 to raw blob */ }
return tagConfig;
}
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// live-edit side (<c>AddressSpaceComposer.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.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): byte-parity with
// AddressSpaceComposer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write.
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
? hEl.GetBoolean()
: null;
return new EquipmentTagAlarmInfo(type, sev, historize);
}
catch (JsonException) { return null; }
}
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
/// <c>ExtractTagAlarm</c>. Never throws. The live-edit composer side
/// (<c>AddressSpaceComposer.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
&& hEl.GetBoolean();
string? tagname = null;
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
&& nEl.ValueKind == JsonValueKind.String)
{
var raw = nEl.GetString();
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
}
return (isHistorized, tagname);
}
catch (JsonException) { return (false, null); }
}
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
/// <c>arrayLength</c> uint (honoured ONLY when <c>isArray</c> is true AND the prop is a JSON number
/// that fits <c>uint</c>; else <c>null</c>). Mirrors <see cref="ExtractTagHistorize"/> in structure +
/// null/blank/non-object/malformed-JSON tolerance. Never throws. The live-edit composer side
/// (<c>AddressSpaceComposer.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
private static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
&& aEl.GetBoolean();
uint? arrayLength = null;
if (isArray
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
&& lEl.ValueKind == JsonValueKind.Number
&& lEl.TryGetUInt32(out var len))
{
arrayLength = len;
}
return (isArray, arrayLength);
}
catch (JsonException) { return (false, null); }
}
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
where T : class
{
@@ -819,7 +714,7 @@ public static class DeploymentArtifact
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
/// the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> so the artifact-decode side
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> so the artifact-decode side
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
/// <param name="root">The artifact root element.</param>
@@ -834,7 +729,7 @@ public static class DeploymentArtifact
if (el.ValueKind != JsonValueKind.Object) continue;
var deviceId = ReadString(el, "DeviceId");
if (string.IsNullOrWhiteSpace(deviceId)) continue;
map[deviceId!] = AddressSpaceComposer.TryExtractDeviceHost(ReadString(el, "DeviceConfig"));
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
}
return map;
}
@@ -728,7 +728,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
/// </list>
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
/// <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> (single source of truth), so they compare equal
/// <see cref="DeviceConfigIntent.NormalizeHost"/> (single source of truth), so they compare equal
/// regardless of case/whitespace.
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
@@ -755,7 +755,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost);
var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost);
if (ambiguousHosts.Contains(host)) continue;
if (hostToEquipment.TryGetValue(host, out var existing))
{
@@ -789,7 +789,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
foreach (var n in msg.Nodes)
{
var key = n.FolderPathSegments.Count >= 2
? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1])
? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1])
: null;
if (key is not null && hostToEquipment.ContainsKey(key))
{
@@ -0,0 +1,46 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// Pins <see cref="DeviceConfigIntent"/> device-host extraction + normalization semantics (ported
/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace,
/// trim+lower normalization, and idempotence.
/// </summary>
public sealed class DeviceConfigIntentTests
{
[Theory]
[InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")]
[InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower
[InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")]
// null-yielding cases
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("not json {", null)]
[InlineData("[1,2]", null)] // non-object root
[InlineData("{\"HostAddress\":123}", null)] // non-string
[InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value
[InlineData("{\"other\":\"x\"}", null)] // absent
public void TryExtractHost(string? cfg, string? expected)
{
DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected);
}
[Theory]
[InlineData(" HOST:8193 ", "host:8193")]
[InlineData("host:8193", "host:8193")]
public void NormalizeHost_trims_and_lowercases(string raw, string expected)
{
DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected);
}
[Fact]
public void NormalizeHost_is_idempotent()
{
var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 ");
DeviceConfigIntent.NormalizeHost(once).ShouldBe(once);
}
}
@@ -0,0 +1,128 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
/// Ports the three retired <c>OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests</c> tables and
/// adds the <see cref="TagConfigIntent.FullName"/> / <see cref="TagConfigIntent.ExplicitFullName"/>
/// distinction plus the never-throws property.
/// </summary>
public sealed class TagConfigIntentTests
{
// ---- FullName / ExplicitFullName ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed)
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")]
// fallbacks: FullName = raw blob, ExplicitFullName = null
[InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)]
[InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)]
[InlineData("[1,2]", "[1,2]", null)]
[InlineData("not json {", "not json {", null)]
[InlineData(" ", " ", null)]
public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit)
{
var intent = TagConfigIntent.Parse(cfg);
intent.FullName.ShouldBe(expectedFullName);
intent.ExplicitFullName.ShouldBe(expectedExplicit);
}
[Fact]
public void Parse_null_yields_empty_FullName_and_null_explicit()
{
var intent = TagConfigIntent.Parse(null);
intent.FullName.ShouldBe(string.Empty);
intent.ExplicitFullName.ShouldBeNull();
intent.Alarm.ShouldBeNull();
intent.IsHistorized.ShouldBeFalse();
intent.IsArray.ShouldBeFalse();
}
// ---- Alarm (ported from ExtractTagAlarmTests) ----
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
[InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500
[InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default
public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev)
{
var alarm = TagConfigIntent.Parse(cfg).Alarm;
if (!present) { alarm.ShouldBeNull(); return; }
alarm!.AlarmType.ShouldBe(type);
alarm.Severity.ShouldBe(sev);
}
[Theory]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
public void Alarm_historizeToAveva(string cfg, bool? expected)
{
TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected);
}
// ---- Historize ----
[Theory]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
[InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
[InlineData("{\"FullName\":\"A\"}", false, null)]
public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
{
var intent = TagConfigIntent.Parse(cfg);
intent.IsHistorized.ShouldBe(expectedHistorized);
intent.HistorianTagname.ShouldBe(expectedTagname);
}
// ---- Array (ported from ExtractTagArrayTests) ----
[Theory]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
[InlineData("{\"isArray\":\"yes\"}", false, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
{
var intent = TagConfigIntent.Parse(cfg);
intent.IsArray.ShouldBe(expectedIsArray);
intent.ArrayLength.ShouldBe(expectedLength);
}
// ---- never-throws property over an adversarial set ----
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json {")]
[InlineData("[1,2,3]")]
[InlineData("42")]
[InlineData("\"just a string\"")]
[InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")]
public void Parse_never_throws(string? cfg)
{
Should.NotThrow(() => TagConfigIntent.Parse(cfg));
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's <em>explicit</em>-FullName
/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to
/// <c>TagConfigIntent.Parse(...).ExplicitFullName</c>. Distinct from the walker/composer raw-blob
/// fallback: <c>GalaxyTagMissingReference</c> fires whenever the TagConfig has no usable
/// <em>explicit</em> string <c>FullName</c> — i.e. absent / non-string / whitespace / non-object /
/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on
/// the unmodified tree and stay green through the swap.
/// </summary>
public sealed class DraftValidatorGalaxyFullNameCorpusTests
{
[Theory]
// Present, non-blank string FullName → rule does NOT fire
[InlineData("{\"FullName\":\"X.Y\"}", false)]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)]
// Absent / non-string / whitespace / non-object / malformed → rule FIRES
[InlineData("{}", true)]
[InlineData("{\"region\":\"HoldingRegisters\"}", true)]
[InlineData("{\"FullName\":123}", true)]
[InlineData("{\"FullName\":\" \"}", true)]
[InlineData("[1,2]", true)]
[InlineData("not json {", true)]
public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire)
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1",
Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
},
],
Tags =
[
new Tag
{
TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1",
Name = "galaxytag", FolderPath = null, DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
},
],
};
var fired = DraftValidator.Validate(draft)
.Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
fired.ShouldBe(shouldFire);
}
}
@@ -0,0 +1,79 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Pins the shared strict-capable TagConfig field readers (R2-11, 05/CONV-2): the
/// <see cref="TagConfigJson.TryReadEnum{TEnum}"/> Absent/Valid/Invalid matrix (case-insensitive),
/// <see cref="TagConfigJson.ReadEnumOrDefault{TEnum}"/> lenient equivalence to the retired copies,
/// <see cref="TagConfigJson.ReadWritable"/> explicit-false-only semantics, and
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/> messages.
/// </summary>
public sealed class TagConfigJsonTests
{
/// <summary>Sample enum for the reader matrix (public so it can be an <c>[InlineData]</c> arg).</summary>
public enum Sample { Alpha, Beta, Gamma }
private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement;
[Theory]
[InlineData("{\"k\":\"Beta\"}", JsonEnumRead.Valid, Sample.Beta)]
[InlineData("{\"k\":\"beta\"}", JsonEnumRead.Valid, Sample.Beta)] // case-insensitive
[InlineData("{\"k\":\"GAMMA\"}", JsonEnumRead.Valid, Sample.Gamma)]
[InlineData("{\"k\":\"Deltaa\"}", JsonEnumRead.Invalid, default(Sample))] // typo → Invalid
[InlineData("{}", JsonEnumRead.Absent, default(Sample))] // absent
[InlineData("{\"k\":123}", JsonEnumRead.Absent, default(Sample))] // non-string → Absent (lenient)
[InlineData("{\"k\":null}", JsonEnumRead.Absent, default(Sample))]
public void TryReadEnum_matrix(string json, JsonEnumRead expected, Sample expectedValue)
{
var outcome = TagConfigJson.TryReadEnum<Sample>(Root(json), "k", out var v);
outcome.ShouldBe(expected);
v.ShouldBe(expectedValue);
}
[Theory]
[InlineData("{\"k\":\"Gamma\"}", Sample.Gamma)] // valid → parsed
[InlineData("{\"k\":\"typo\"}", Sample.Alpha)] // invalid → fallback
[InlineData("{}", Sample.Alpha)] // absent → fallback
[InlineData("{\"k\":42}", Sample.Alpha)] // non-string → fallback
public void ReadEnumOrDefault_is_lenient(string json, Sample expected)
{
TagConfigJson.ReadEnumOrDefault(Root(json), "k", Sample.Alpha).ShouldBe(expected);
}
[Theory]
[InlineData("{\"writable\":false}", true, false)] // explicit false → false
[InlineData("{\"writable\":true}", true, true)] // explicit true → default
[InlineData("{}", true, true)] // absent → default
[InlineData("{\"writable\":\"no\"}", true, true)] // non-bool → default
[InlineData("{}", false, false)] // absent, default false
[InlineData("{\"writable\":false}", false, false)] // explicit false, default false
public void ReadWritable_explicit_false_only(string json, bool dflt, bool expected)
{
TagConfigJson.ReadWritable(Root(json), dflt).ShouldBe(expected);
}
[Fact]
public void DescribeInvalidEnum_names_field_value_and_valid_members()
{
var msg = TagConfigJson.DescribeInvalidEnum<Sample>(Root("{\"dataType\":\"Betaa\"}"), "dataType");
msg.ShouldNotBeNull();
msg.ShouldContain("Betaa");
msg.ShouldContain("dataType");
msg.ShouldContain("Alpha");
msg.ShouldContain("Beta");
msg.ShouldContain("Gamma");
}
[Theory]
[InlineData("{\"dataType\":\"Beta\"}")] // valid → null
[InlineData("{}")] // absent → null
[InlineData("{\"dataType\":5}")] // non-string → null
public void DescribeInvalidEnum_null_when_absent_or_valid(string json)
{
TagConfigJson.DescribeInvalidEnum<Sample>(Root(json), "dataType").ShouldBeNull();
}
}
@@ -0,0 +1,36 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
/// <summary>
/// Characterization net (R2-11) pinning <see cref="EquipmentNodeWalker.ExtractFullName"/>'s
/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to
/// <c>TagConfigIntent.Parse</c>. The walker's FullName copy falls back to the raw blob whenever the
/// input is not a JSON object carrying a string <c>FullName</c> (blank / non-object / malformed /
/// absent / non-string). Whitespace + non-string-property values are returned verbatim (not trimmed).
/// Must be green on the unmodified tree and stay green through the swap.
/// </summary>
public sealed class EquipmentNodeWalkerFullNameCorpusTests
{
[Theory]
// object with a string FullName → the FullName string (verbatim, incl. whitespace)
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y")]
[InlineData("{\"FullName\":\" \"}", " ")]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5}", "A.B")]
// blank → raw blob (IsNullOrWhiteSpace short-circuit)
[InlineData(" ", " ")]
// non-object root → raw blob
[InlineData("[1,2]", "[1,2]")]
// malformed JSON → raw blob
[InlineData("not json {", "not json {")]
// object, FullName absent → raw blob
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":5}", "{\"region\":\"HoldingRegisters\",\"address\":5}")]
// object, FullName present but non-string → raw blob
[InlineData("{\"FullName\":123}", "{\"FullName\":123}")]
public void ExtractFullName_falls_back_to_raw_blob_except_for_string_FullName(string tagConfig, string expected)
{
EquipmentNodeWalker.ExtractFullName(tagConfig).ShouldBe(expected);
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbCip equipment-tag parser: lenient runtime freeze,
/// <c>Inspect</c> reports the typo, and the already-honoured <c>writable</c> key stays bit-identical through
/// the shared reader swap.</summary>
public sealed class AbCipEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_DInt()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured_bit_identical()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbLegacy equipment-tag parser: lenient runtime
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class AbLegacyEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbLegacyDataType.Int);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Int\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series'
/// capability matrix must fail to RESOLVE (surfacing <c>BadNodeIdUnknown</c>) — the same pre-flight
/// authored tags get at <c>InitializeAsync</c> — instead of reaching the wire and failing later with a
/// wire error. A capability-valid equipment tag still resolves + reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasEquipmentTagCapabilityGateTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver() => new(new FocasDriverOptions
{
// Series 16i: macro range 0..999 — MACRO:9500 is out of range.
Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
private static string EquipTag(string address) =>
$"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}";
[Fact]
public async Task Capability_violating_equipment_tag_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Capability_valid_equipment_tag_resolves_and_reads()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None);
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Equipment_tag_for_unknown_device_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}";
var results = await drv.ReadAsync([badDevice], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
}
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 (05/CONV-2 + 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: lenient
/// runtime freeze on <c>dataType</c>, forced read-only (<c>Writable == false</c> regardless of any
/// authored <c>writable:true</c>), and <c>Inspect</c> reporting the typo + the write-request.
/// </summary>
public sealed class FocasEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int32()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(FocasDataType.Int32);
}
[Fact]
public void Writable_is_always_false_even_when_requested_true()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_is_false_when_absent()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Inspect_warns_on_writable_true_request()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
}
[Fact]
public void Inspect_clean_read_only_config_has_no_warnings()
{
FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
}
}
@@ -0,0 +1,56 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// R2-11 (05/CONV-2): the Modbus probe parses the SAME factory DTO shape the driver factory parses, so
/// <c>timeoutMs</c> binds identically instead of being silently dropped (it never bound to the runtime
/// <c>ModbusDriverOptions.Timeout</c> TimeSpan). The OpcUaClient probe/factory parse-parity rule.
/// </summary>
public sealed class ModbusDriverProbeParityTests
{
[Fact]
public void Config_timeoutMs_binds_as_the_effective_probe_timeout()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":3,\"timeoutMs\":250}",
fallbackTimeout: TimeSpan.FromSeconds(5));
error.ShouldBeNull();
target.ShouldNotBeNull();
target!.Value.Host.ShouldBe("10.0.0.5");
target.Value.Port.ShouldBe(502);
target.Value.UnitId.ShouldBe((byte)3);
target.Value.Timeout.ShouldBe(TimeSpan.FromMilliseconds(250));
}
[Fact]
public void Absent_timeoutMs_falls_back_to_the_caller_timeout()
{
var (target, _) = ModbusDriverProbe.TryParseProbeTarget(
"{\"host\":\"10.0.0.5\",\"port\":502}", fallbackTimeout: TimeSpan.FromSeconds(5));
target!.Value.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
target.Value.UnitId.ShouldBe((byte)1); // DTO default
}
[Fact]
public void Missing_host_is_an_error()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"{\"port\":0}", fallbackTimeout: TimeSpan.FromSeconds(5));
target.ShouldBeNull();
error.ShouldNotBeNull();
}
[Fact]
public void Invalid_json_is_an_error()
{
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
"not valid json {{", fallbackTimeout: TimeSpan.FromSeconds(5));
target.ShouldBeNull();
error.ShouldContain("invalid");
}
}
@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// R2-11 (05/CONV-2) strictness surface for the Modbus equipment-tag parser: the runtime stays lenient
/// (freeze), the new <c>Inspect</c> reports the silently-defaulted typo, and the <c>writable</c> key is
/// now honoured (default true).
/// </summary>
public sealed class ModbusEquipmentTagParserStrictnessTests
{
// ---- Phase-A behaviour freeze: a typo'd enum still silently defaults ----
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int16()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(ModbusDataType.Int16);
}
// ---- Inspect reports the typo ----
[Fact]
public void Inspect_reports_typo_dataType_with_field_and_valid_values()
{
var warnings = ModbusEquipmentTagParser.Inspect(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
warnings[0].ShouldContain("Int16");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
ModbusEquipmentTagParser.Inspect(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
.ShouldBeEmpty();
}
[Fact]
public void Inspect_malformed_json_warns_unresolvable()
{
ModbusEquipmentTagParser.Inspect("{not json").ShouldHaveSingleItem();
}
// ---- writable now honoured ----
[Fact]
public void Writable_false_is_honoured()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
ModbusEquipmentTagParser.TryParse(
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the S7 equipment-tag parser: lenient runtime freeze,
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class S7EquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_Int16()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(S7DataType.Int16);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>R2-11 (05/CONV-2) strictness surface for the TwinCAT equipment-tag parser: lenient runtime
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class TwinCATEquipmentTagParserStrictnessTests
{
[Fact]
public void Freeze_typo_dataType_still_defaults_to_DInt()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// R2-11 (05/UNDER-6): the new optional <c>writable</c> key round-trips through the driver-typed tag
/// config models — a value the editors' new checkbox authors. Absent stays absent (so existing configs
/// are untouched); an explicit <c>false</c> round-trips; unknown keys are preserved.
/// </summary>
public sealed class TagConfigModelWritableTests
{
[Fact]
public void Modbus_absent_writable_stays_omitted()
{
var m = ModbusTagConfigModel.FromJson("{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}");
m.Writable.ShouldBeNull();
m.ToJson().ShouldNotContain("writable");
}
[Fact]
public void Modbus_explicit_false_round_trips()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false}");
m.Writable.ShouldBe(false);
var json = ModbusTagConfigModel.FromJson(m.ToJson());
json.Writable.ShouldBe(false);
}
[Fact]
public void Modbus_setting_writable_true_writes_the_key()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1}");
m.Writable = true;
ModbusTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(true);
}
[Fact]
public void Writable_preserves_unknown_keys()
{
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false,\"foo\":\"bar\"}");
m.ToJson().ShouldContain("foo");
m.ToJson().ShouldContain("bar");
}
[Fact]
public void S7_writable_round_trips()
{
var m = S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\",\"writable\":false}");
m.Writable.ShouldBe(false);
S7TagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void AbCip_writable_round_trips()
{
var m = AbCipTagConfigModel.FromJson("{\"tagPath\":\"Motor.Speed\",\"writable\":false}");
m.Writable.ShouldBe(false);
AbCipTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void AbLegacy_writable_round_trips()
{
var m = AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\",\"writable\":false}");
m.Writable.ShouldBe(false);
AbLegacyTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void TwinCAT_writable_round_trips()
{
var m = TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\",\"writable\":false}");
m.Writable.ShouldBe(false);
TwinCATTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
}
[Fact]
public void Absent_writable_omitted_across_models()
{
ModbusTagConfigModel.FromJson("{\"address\":1}").ToJson().ShouldNotContain("writable");
S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\"}").ToJson().ShouldNotContain("writable");
AbCipTagConfigModel.FromJson("{\"tagPath\":\"M.S\"}").ToJson().ShouldNotContain("writable");
AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\"}").ToJson().ShouldNotContain("writable");
TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\"}").ToJson().ShouldNotContain("writable");
}
}
@@ -0,0 +1,131 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R2-11 (05/CONV-2) — the deploy gate's TagConfig strictness inspection, exercised THROUGH the actor
/// (the F10b/PR#423 lesson: an inspector built but never called is inert). Warn mode (default) accepts
/// the deploy with the warning surfaced in the result Message; Error mode rejects the same draft; a
/// clean config and an unmapped (Galaxy) tag produce no TagConfig noise.
/// </summary>
public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTestBase
{
private static void Seed(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string driverType, string tagConfig,
string tagName = "flow")
{
var uuid = Guid.NewGuid();
var equipmentId = DraftValidator.DeriveEquipmentId(uuid);
using var db = dbFactory.CreateDbContext();
db.Namespaces.Add(new Namespace
{
NamespaceId = "ns-1", ClusterId = "", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "d-1", ClusterId = "", NamespaceId = "ns-1",
Name = "drv", DriverType = driverType, DriverConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "pump-01",
DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "PUMP01",
});
db.Tags.Add(new Tag
{
TagId = "tag-1", DriverInstanceId = "d-1", EquipmentId = equipmentId,
Name = tagName, DataType = "Int16", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
});
db.SaveChanges();
}
private const string TypoModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}";
private const string CleanModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}";
[Fact]
public void Warn_mode_accepts_and_surfaces_the_warning_in_the_message()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", TypoModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("Intt16");
reply.Message.ShouldContain("tag-1");
}
[Fact]
public void Error_mode_rejects_the_same_draft()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", TypoModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldContain("TagConfigInvalid");
reply.Message.ShouldContain("Intt16");
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Count().ShouldBe(0);
}
[Fact]
public void Clean_config_accepts_with_no_tagconfig_noise()
{
var dbFactory = NewInMemoryDbFactory();
Seed(dbFactory, "Modbus", CleanModbus);
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
(reply.Message is null || !reply.Message.Contains("Intt16")).ShouldBeTrue();
}
[Fact]
public void Unmapped_galaxy_tag_is_skipped_even_in_error_mode()
{
var dbFactory = NewInMemoryDbFactory();
// Galaxy tag carrying a FullName (passes the DraftValidator Galaxy rule); the inspector has no
// Galaxy mapping, so it is skipped regardless of mode.
Seed(dbFactory, "GalaxyMxGateway", "{\"FullName\":\"Obj.Attr\",\"dataType\":\"nonsense\"}");
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3)).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
}
}
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers
/// route to their parser's Inspect; unmapped drivers (Galaxy/OpcUaClient) skip; keys are case-insensitive.</summary>
public sealed class EquipmentTagConfigInspectorTests
{
[Fact]
public void Modbus_typo_dispatches_to_one_warning()
{
var warnings = EquipmentTagConfigInspector.Inspect(
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
}
[Fact]
public void Clean_modbus_config_has_no_warnings()
{
EquipmentTagConfigInspector.Inspect(
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
.ShouldBeEmpty();
}
[Theory]
[InlineData("GalaxyMxGateway")]
[InlineData("OpcUaClient")]
[InlineData("Unknown")]
public void Unmapped_driver_types_are_skipped(string driverType)
{
EquipmentTagConfigInspector.Inspect(driverType, "{\"dataType\":\"whatever\"}").ShouldBeEmpty();
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse();
}
[Theory]
[InlineData("modbus")]
[InlineData("FOCAS")]
[InlineData("focas")]
[InlineData("twincat")]
public void Driver_type_matching_is_case_insensitive(string driverType)
{
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue();
}
[Fact]
public void Focas_writable_true_dispatches_read_only_warning()
{
var warnings = EquipmentTagConfigInspector.Inspect(
"Focas", "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only"));
}
[Fact]
public void Null_inputs_are_safe()
{
EquipmentTagConfigInspector.Inspect(null, "{}").ShouldBeEmpty();
EquipmentTagConfigInspector.Inspect("Modbus", null).ShouldBeEmpty();
}
}
@@ -8,8 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// Covers follow-up E projection: <see cref="EquipmentNode"/> carries the equipment's
/// <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the resolved <c>DeviceHost</c> (parsed from
/// the bound <see cref="Device"/>'s schemaless <c>DeviceConfig</c> JSON via the shared
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>). A later task grafts a driver's discovered
/// <c>DeviceConfigIntent.TryExtractHost</c>). A later task grafts a driver's discovered
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
/// <para>The direct host-extraction + normalization unit tests moved to
/// <c>Commons.Tests/DeviceConfigIntentTests</c> (R2-11) — this suite keeps the Compose-level projection
/// coverage.</para>
/// </summary>
public sealed class AddressSpaceComposerDeviceHostTests
{
@@ -75,36 +78,6 @@ public sealed class AddressSpaceComposerDeviceHostTests
noHost.DeviceHost.ShouldBeNull();
}
/// <summary>The shared host extractor normalizes (trim + lower-case) and tolerates every malformed
/// shape (blank / non-object / no string HostAddress / blank value / non-JSON) by returning null.</summary>
[Theory]
[InlineData("{\"HostAddress\":\"10.201.31.5:8193\"}", "10.201.31.5:8193")]
[InlineData("{\"HostAddress\":\" HOST-A:8193 \"}", "host-a:8193")] // trimmed + lower-cased
[InlineData("{\"HostAddress\":\"\"}", null)] // blank value
[InlineData("{\"HostAddress\":1234}", null)] // non-string
[InlineData("{\"Port\":502}", null)] // absent
[InlineData("[]", null)] // non-object root
[InlineData("not json", null)] // malformed
[InlineData("", null)] // blank
public void TryExtractDeviceHost_normalizes_and_tolerates(string? deviceConfig, string? expected)
{
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
}
/// <summary>The extracted-out shared normalizer (the single source of truth the FixedTree-partition path
/// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an
/// already-normalized value — so a segment like <c>" HOST-A:8193 "</c> matches a stored
/// <c>"host-a:8193"</c> DeviceHost.</summary>
[Theory]
[InlineData("10.201.31.5:8193", "10.201.31.5:8193")]
[InlineData(" HOST-A:8193 ", "host-a:8193")]
[InlineData("host-a:8193", "host-a:8193")] // idempotent
[InlineData("H1", "h1")]
public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected)
{
AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected);
}
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
{
EquipmentId = id,
@@ -1,37 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public class ExtractTagAlarmTests
{
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)]
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
{
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
if (!present) { info.ShouldBeNull(); return; }
info!.AlarmType.ShouldBe(type);
info.Severity.ShouldBe(sev);
}
/// <summary>historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses
/// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as
/// default-on). Mirrors the scripted-alarm opt-out posture.</summary>
[Theory]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
public void ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected)
{
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
info.ShouldNotBeNull();
info!.HistorizeToAveva.ShouldBe(expected);
}
}
@@ -1,159 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies <see cref="AddressSpaceComposer.ExtractTagArray"/> parses the optional array intent from a
/// tag's <c>TagConfig</c> JSON exactly as <see cref="AddressSpaceComposer.ExtractTagHistorize"/> parses
/// the historize intent: the <c>isArray</c> bool (absent / not a bool / non-object root / blank /
/// malformed ⇒ <c>false</c>) and the optional <c>arrayLength</c> uint (only honoured when
/// <c>isArray</c> is true AND the prop is a JSON number that fits uint; else <c>null</c>). Never
/// throws. Also pins the end-to-end <see cref="AddressSpaceComposer.Compose"/> thread-through onto
/// <see cref="EquipmentTagPlan.IsArray"/> / <see cref="EquipmentTagPlan.ArrayLength"/>.
/// </summary>
public class ExtractTagArrayTests
{
[Theory]
// isArray true with an explicit arrayLength.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
// isArray true, no arrayLength ⇒ length null.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
// Absent isArray ⇒ false (arrayLength ignored even if present).
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
// arrayLength present but isArray false ⇒ length null (only honoured when the flag is true).
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
// arrayLength absent-with-flag honoured-as-null when isArray true but no length.
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
// Wrong type for isArray (string, not bool) ⇒ false.
[InlineData("{\"isArray\":\"yes\"}", false, null)]
// Wrong type for arrayLength (string, not number) ⇒ length null, flag still honoured.
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
// Negative arrayLength does not fit uint ⇒ length null, flag still honoured.
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
// Float arrayLength (16.5) is not an exact uint ⇒ TryGetUInt32 rejects it ⇒ length null.
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
// Overflow arrayLength (uint.MaxValue + 1 = 4294967296) does not fit uint ⇒ length null.
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
{
var (isArray, arrayLength) = AddressSpaceComposer.ExtractTagArray(cfg);
isArray.ShouldBe(expectedIsArray);
arrayLength.ShouldBe(expectedLength);
}
/// <summary>End-to-end: an equipment tag whose TagConfig carries <c>isArray</c>/<c>arrayLength</c>
/// surfaces those on its <see cref="EquipmentTagPlan"/> through <see cref="AddressSpaceComposer.Compose"/>,
/// exactly as the historize keys thread through.</summary>
[Fact]
public void Compose_threads_array_keys_onto_equipment_tag_plan()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var arrayTag = new Tag
{
TagId = "tag-arr",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = "Buffer",
DataType = "Int16",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}",
};
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { arrayTag }, new[] { ns });
var tag = result.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-arr");
tag.IsArray.ShouldBeTrue();
tag.ArrayLength.ShouldBe((uint)16);
}
/// <summary>End-to-end: a scalar equipment tag (no array keys) yields IsArray=false, ArrayLength=null.</summary>
[Fact]
public void Compose_leaves_scalar_equipment_tag_plan_unflagged()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var scalarTag = new Tag
{
TagId = "tag-scalar",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = "Speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40005\"}",
};
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { scalarTag }, new[] { ns });
var tag = result.EquipmentTags.ShouldHaveSingleItem();
tag.IsArray.ShouldBeFalse();
tag.ArrayLength.ShouldBeNull();
}
}
@@ -1,35 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public class ExtractTagHistorizeTests
{
[Theory]
// isHistorized true, no explicit tagname ⇒ tagname null (defaults to FullName later).
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true}", true, null)]
// isHistorized true with an explicit historian-tagname override.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\"WW.Tag\"}", true, "WW.Tag")]
// Absent isHistorized ⇒ false.
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
// historianTagname parses independently of the flag.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":false,\"historianTagname\":\"WW.Tag\"}", false, "WW.Tag")]
// Blank/whitespace tagname ⇒ null.
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)]
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
[InlineData(null, false, null)]
[InlineData("", false, null)]
[InlineData("not json {", false, null)]
[InlineData("[1,2]", false, null)]
// Wrong type for isHistorized (string, not bool) ⇒ false.
[InlineData("{\"isHistorized\":\"yes\"}", false, null)]
// Wrong type for historianTagname (number, not string) ⇒ tagname null, flag still honoured.
[InlineData("{\"isHistorized\":true,\"historianTagname\":123}", true, null)]
public void ExtractTagHistorize_parses_or_returns_defaults(string? cfg, bool expectedHistorized, string? expectedTagname)
{
var (isHistorized, historianTagname) = AddressSpaceComposer.ExtractTagHistorize(cfg);
isHistorized.ShouldBe(expectedHistorized);
historianTagname.ShouldBe(expectedTagname);
}
}
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// and the artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the
/// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve
/// the host through the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> (single source
/// the host through the shared <c>DeviceConfigIntent.TryExtractHost</c> (single source
/// of truth + identical trim + lower-case normalization).
/// </summary>
public sealed class DeploymentArtifactDeviceHostParityTests
@@ -197,7 +197,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[Fact]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{
@@ -0,0 +1,111 @@
using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
/// <see cref="TagConfigGoldenCorpus"/> blob, serialises the SAME draft to the artifact blob shape,
/// decodes it, and asserts the decoded <see cref="EquipmentTagPlan"/> list equals the composer's
/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
/// the identical raw <c>TagConfig</c> string, this proves the four byte-parity parse sites agree TODAY,
/// and is the permanent regression guard held green through every subsequent seam swap
/// (<c>TagConfigIntent.Parse</c> consolidation).
/// </summary>
public sealed class TagConfigCorpusParityTests
{
[Fact]
public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "Machine_001",
MachineCode = "MACHINE_001",
};
var tags = TagConfigGoldenCorpus.Blobs
.Select((blob, i) => new Tag
{
TagId = $"tag-{i:D2}",
DriverInstanceId = "drv-1",
EquipmentId = "eq-1",
FolderPath = null,
Name = $"Sig{i:D2}",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = blob,
})
.ToArray();
// ---- Side 1: the live-edit composer ----
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(), tags, new[] { ns });
// ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind },
},
DriverInstances = new[]
{
new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
},
Tags = tags.Select(ToSnapshot).ToArray(),
});
var decoded = DeploymentArtifact.ParseComposition(blob);
decoded.EquipmentTags.Count.ShouldBe(tags.Length);
// Full byte-parity: every field, same order (positional-record value equality). A field-by-field
// spell-out per blob so a divergence names the offending corpus entry.
for (var i = 0; i < composed.EquipmentTags.Count; i++)
{
var c = composed.EquipmentTags[i];
var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId);
d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'");
}
decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue();
}
private static object ToSnapshot(Tag t) => new
{
t.TagId,
t.DriverInstanceId,
t.EquipmentId,
t.Name,
t.FolderPath,
t.DataType,
AccessLevel = (int)t.AccessLevel,
t.TagConfig,
};
}
@@ -0,0 +1,83 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The shared golden corpus of <c>TagConfig</c> JSON blobs used to characterise the cross-driver
/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength).
/// Every entry is a NON-NULL string (the DB <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a
/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
/// (<see cref="TagConfigCorpusParityTests"/>) can round-trip each through both equipment-tag producers
/// and assert byte-parity field-by-field. The <c>null</c>-input divergence (composer returns the raw
/// null, artifact coalesces to "") is a documented seam handled only at the
/// <c>TagConfigIntent.Parse</c> unit level, not through the parity round-trip.
/// <para>Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace,
/// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool,
/// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/
/// length-without-flag/zero), unknown keys, and mixed platform+driver keys.</para>
/// </summary>
public static class TagConfigGoldenCorpus
{
/// <summary>The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.</summary>
public static readonly IReadOnlyList<string> Blobs = new[]
{
// 0 happy path
"{\"FullName\":\"X.Y\"}",
// 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams
" ",
// 2 non-object root
"[1,2]",
// 3 malformed JSON
"not json {",
// 4 FullName absent (driver keys only) — FullName = raw blob
"{\"region\":\"HoldingRegisters\",\"address\":5}",
// 5 FullName present but non-string — FullName = raw blob
"{\"FullName\":123}",
// 6 FullName whitespace value (not trimmed)
"{\"FullName\":\" \"}",
// 7 alarm empty object → defaults (AlarmCondition, 500, historize null)
"{\"FullName\":\"A\",\"alarm\":{}}",
// 8 alarm fully specified
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
// 9 alarm non-object → no alarm
"{\"FullName\":\"A\",\"alarm\":\"oops\"}",
// 10 alarm historizeToAveva true
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}",
// 11 alarm historizeToAveva false
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}",
// 12 alarm historizeToAveva non-bool → null
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}",
// 13 alarm severity non-number → 500
"{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}",
// 14 alarm alarmType non-string → AlarmCondition
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}",
// 15 isHistorized true
"{\"FullName\":\"A\",\"isHistorized\":true}",
// 16 isHistorized true + explicit tagname
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}",
// 17 isHistorized false + JSON-null tagname
"{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}",
// 18 historianTagname whitespace → null
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}",
// 19 isHistorized non-bool → false
"{\"FullName\":\"A\",\"isHistorized\":\"yes\"}",
// 20 isArray true + length
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}",
// 21 isArray true, no length
"{\"FullName\":\"A\",\"isArray\":true}",
// 22 isArray false + length → scalar
"{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}",
// 23 isArray true, length 0 → true, 0
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}",
// 24 arrayLength non-number → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}",
// 25 arrayLength negative → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}",
// 26 arrayLength float → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}",
// 27 arrayLength overflow (uint.MaxValue + 1) → true, null
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}",
// 28 unknown + mixed platform/driver keys
"{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}",
// 29 all intents combined
"{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}",
};
}