From 2947a2b9e208550b14f888f18fa3375e497f3715 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 21:14:32 -0400 Subject: [PATCH 1/2] feat(drivers): Contracts-side TagConfig Inspect for Sql, MTConnect, Calculation and OpcUaClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferment-register G-7: these four were validated by the AdminUI's TagConfigValidator at authoring but by nothing at deploy, so config arriving by CSV import or API met no gate at all. The deploy gate dispatches to a driver's *TagDefinitionFactory.Inspect in its Contracts assembly, and none of the four had one. Rather than mirror the AdminUI models (a mirror of an accept/reject boundary drifts silently, with the gate describing a shape the driver no longer reads), each Inspect is built on the driver's own runtime parse: - Sql wraps the existing SqlEquipmentTagParser, which already lived in Sql.Contracts. The P3-deferred Query model stays refused; Inspect surfaces the refusal rather than weakening it. - MTConnect's blob reader MOVED out of MTConnectDriver (it was private, with a private DTO) into MTConnectTagDefinitionFactory; the driver now calls it. Behaviour unchanged. - OpcUaClient's nodeId read MOVED off NamespaceMap into OpcUaClientTagDefinitionFactory; NamespaceMap now calls it. Behaviour unchanged. - Calculation gains a Contracts assembly it never had. Only the JSON reading moved there (CalculationTagDefinitionFactory.TryReadTagConfig); building a CalculationTagDefinition still needs EquipmentScriptPaths from Commons and stays in the driver, so the split is at the blob boundary. The new project is deliberately reference-free — a calc blob is strings, a bool and an int, with no Core.Abstractions type in it. Each Inspect runs the parse FIRST and returns empty on success, so "never stricter than the runtime" is structural rather than a promise: a warning is only reachable for a blob the driver would have refused and then skipped silently. Calculation additionally reports two present-but-silently-discarded fields (a non-boolean changeTriggered reads as true; a non-positive-integer timerIntervalMs is dropped), and deliberately does NOT warn about a trigger-less tag or a sub-50ms timer — the editor refuses both, the runtime honours both, and the deploy gate follows the runtime. --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../CalculationTagDefinitionFactory.cs | 161 ++++++++++++ ...tOpcUa.Driver.Calculation.Contracts.csproj | 22 ++ .../CalculationTagDefinition.cs | 46 +--- ...B.MOM.WW.OtOpcUa.Driver.Calculation.csproj | 3 + .../MTConnectTagDefinitionFactory.cs | 230 ++++++++++++++++++ .../MTConnectDriver.cs | 123 +--------- .../NamespaceMap.cs | 30 +-- .../OpcUaClientTagDefinitionFactory.cs | 95 ++++++++ .../SqlTagDefinitionFactory.cs | 183 ++++++++++++++ 10 files changed, 709 insertions(+), 185 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 0d9e5f18..f9170dfc 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -22,6 +22,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs new file mode 100644 index 00000000..37ca4929 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs @@ -0,0 +1,161 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; + +/// +/// The four fields a calc tag's TagConfig blob carries, as +/// read them. Deliberately not the +/// driver's CalculationTagDefinition: that record additionally carries the tag's RawPath identity +/// and the dependency refs extracted from the script source, neither of which is in the blob. +/// +/// Logical FK to the shared Script entity that computes the tag; never blank. +/// The resolved C# script source; empty when the blob carried none. +/// Re-evaluate whenever a declared dependency changes (default ). +/// Periodic re-evaluation cadence in ms, or for change-trigger only. +public readonly record struct CalculationTagConfigFields( + string ScriptId, + string ScriptSource, + bool ChangeTriggered, + int? TimerIntervalMs); + +/// +/// The authored-raw-tag reader for the Calculation pseudo-driver: one authority, consumed by both +/// the runtime (CalculationTagDefinition.TryFromTagConfig, which adds the RawPath identity and the +/// script's dependency refs on top) and the deploy-time TagConfig gate (, +/// deferment-register G-7). +/// +/// The JSON reading moved here from the driver; the definition-building did not. Building a +/// CalculationTagDefinition needs EquipmentScriptPaths.ExtractDependencyRefs, which +/// lives in Commons and is genuinely runtime infrastructure — dragging it into a Contracts leaf to +/// co-locate the whole thing would make every consumer of this gate carry the scripting stack. The +/// split is at the blob boundary: everything the blob says is read here, once. +/// +/// +/// is never stricter than . It reports +/// only two things: a blob the read REJECTS (which the driver skips as "not a calc tag"), and fields +/// that are present but in a shape the lenient read silently discards — changeTriggered that +/// is not a JSON boolean (read as ) and timerIntervalMs that is not a +/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently +/// defaulted" warning class. +/// +/// +/// What it deliberately does NOT warn about: a tag with no trigger at all +/// (changeTriggered:false and no timer). The AdminUI's CalculationTagConfigModel.Validate +/// rejects that shape and the runtime accepts it, and this gate follows the runtime — it may +/// never call a blob the driver serves invalid, because the deploy gate can be configured to turn its +/// warnings into blocking errors. The same reasoning covers a sub-50 ms timer interval, which the +/// editor refuses and the driver honours. +/// +/// +public static class CalculationTagDefinitionFactory +{ + /// + /// Reads a calc tag's TagConfig blob + /// ({ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }). Returns + /// — never throws — only when the blob is unreadable, is not a JSON object, + /// or names no scriptId: a tag with no identifiable script cannot be a calc tag. A present + /// scriptId with an empty/absent scriptSource still reads (the driver logs it and the + /// tag never computes). + /// + /// The calc tag's schemaless TagConfig JSON. + /// The read fields when this returns . + /// when the blob names a script. + public static bool TryReadTagConfig(string tagConfig, out CalculationTagConfigFields fields) + { + fields = default; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + + var scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String + ? sidEl.GetString() : null; + if (string.IsNullOrWhiteSpace(scriptId)) return false; + + var scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String + ? srcEl.GetString() ?? string.Empty : string.Empty; + + // changeTriggered defaults to true (mini-design §2) — only an explicit false disables it. + var changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl) + && ctEl.ValueKind == JsonValueKind.False); + + var timerMs = root.TryGetProperty("timerIntervalMs", out var tEl) + && tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0 + ? ms : (int?)null; + + fields = new CalculationTagConfigFields(scriptId!, scriptSource, changeTriggered, timerMs); + return true; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Deploy-time inspection of a calc tag's TagConfig blob. Warns for a blob + /// rejects (unparseable JSON, a non-object root, or no usable + /// scriptId), and for a present-but-discarded changeTriggered / timerIntervalMs + /// — see the class remarks for what is deliberately NOT warned about. Empty when the blob is clean, + /// blank, or not a JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(reference); + } + catch (JsonException) + { + warnings.Add("Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute."); + return warnings; + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("Calculation TagConfig root is not a JSON object — the driver will skip this tag and it will never compute."); + return warnings; + } + + if (!TryReadTagConfig(reference, out _)) + { + warnings.Add( + "Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not " + + "treat this as a calc tag, skips it, and it never computes."); + } + + // Present-but-discarded fields. The read is lenient here by design, so these say what was + // IGNORED — they never claim the tag is invalid. + if (root.TryGetProperty("changeTriggered", out var ct) + && ct.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + warnings.Add( + $"value '{ct.GetRawText()}' for 'changeTriggered' is not a JSON boolean; anything but an " + + "explicit false is read as TRUE, so this tag is change-triggered whatever the value looks like."); + } + + if (root.TryGetProperty("timerIntervalMs", out var timer) + && !(timer.ValueKind == JsonValueKind.Number && timer.TryGetInt32(out var ms) && ms > 0)) + { + warnings.Add( + $"value '{timer.GetRawText()}' for 'timerIntervalMs' is not a positive whole number of " + + "milliseconds; it is discarded and the tag gets NO periodic re-evaluation."); + } + } + + return warnings; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj new file mode 100644 index 00000000..ad5239ba --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Calculation + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs index 416f3ba2..b08f3f37 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; @@ -40,45 +39,20 @@ public sealed record CalculationTagDefinition( public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def) { def = null!; - if (string.IsNullOrWhiteSpace(tagConfig)) return false; - string? scriptId; - string scriptSource; - bool changeTriggered; - int? timerMs; - try - { - using var doc = JsonDocument.Parse(tagConfig); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) return false; + // The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate + // (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says + // (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency + // refs extracted from the script source. + if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false; - scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String - ? sidEl.GetString() : null; - if (string.IsNullOrWhiteSpace(scriptId)) return false; - - scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String - ? srcEl.GetString() ?? string.Empty : string.Empty; - - // changeTriggered defaults to true (mini-design §2) — only an explicit false disables it. - changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl) - && ctEl.ValueKind == JsonValueKind.False); - - timerMs = root.TryGetProperty("timerIntervalMs", out var tEl) - && tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0 - ? ms : null; - } - catch (JsonException) - { - return false; - } - - var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource); + var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource); def = new CalculationTagDefinition( RawPath: rawPath, - ScriptId: scriptId!, - ScriptSource: scriptSource, - ChangeTriggered: changeTriggered, - TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null, + ScriptId: fields.ScriptId, + ScriptSource: fields.ScriptSource, + ChangeTriggered: fields.ChangeTriggered, + TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null, DependencyRefs: deps); return true; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj index 606fd3e9..0efbc7e0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj @@ -19,6 +19,9 @@ for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext + PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions owns DataValueSnapshot. --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs new file mode 100644 index 00000000..129fba5e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime +/// (MTConnectDriver's RawPath → definition table build) and the deploy-time TagConfig gate +/// (, deferment-register G-7). +/// +/// This parse used to be private to MTConnectDriver. It moved here — unchanged — +/// rather than being mirrored, because a mirror of an accept/reject boundary drifts the moment +/// either side is touched, and the drift is invisible: the gate would go on describing a shape the +/// driver no longer reads. Nothing about the reading changed in the move; the driver now calls +/// where it called its own copy. +/// +/// +/// is never stricter than — it asks the +/// parse for the verdict and only explains a rejection, so a blob the driver serves can never draw +/// a warning calling it invalid. +/// +/// +public static class MTConnectTagDefinitionFactory +{ + /// + /// Reader options, mirroring MTConnectDriver's config reader (from which this moved). Note + /// there is deliberately no JsonStringEnumConverter: the type-carrying field stays a + /// string? and is parsed by name below, which is the project-wide guard against the + /// numerically-serialized-enum trap. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Reads one authored raw tag's TagConfig blob: the DataItem id it binds, and the definition + /// its blob declares ( when the blob declares no type and the caller must fall + /// back to the Agent's /probe declaration). Never throws. + /// + /// The authored equipment-tag TagConfig JSON. + /// The Agent DataItem id the tag binds, when this returns . + /// The definition the blob declared, or when it declared no type. + /// + /// when the blob is unreadable, names no DataItem id, or carries a + /// driverDataType/dataType that is not a — a + /// present-but-invalid type rejects the tag rather than silently defaulting, which would publish the + /// Agent's text as a Good value of the wrong type (the sibling drivers' strict-enum posture). + /// + public static bool TryFromTagConfig(string? tagConfig, out string dataItemId, out MTConnectTagDefinition? authored) + { + dataItemId = string.Empty; + authored = null; + + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + + MTConnectRawTagConfigDto? blob; + try + { + blob = JsonSerializer.Deserialize(tagConfig, JsonOptions); + } + catch (JsonException) + { + return false; + } + + if (blob is null) return false; + + var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address); + if (id is null) return false; + + dataItemId = id; + + // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed + // MTConnectTagConfigModel writes) — see the DTO remarks. + var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); + if (authoredType is null) return true; + + if (!TryParseDataType(authoredType, out var parsed)) return false; + + authored = new MTConnectTagDefinition( + dataItemId, + parsed, + blob.IsArray ?? false, + blob.ArrayDim ?? 0, + blob.MtCategory, + blob.MtType, + blob.MtSubType, + blob.Units); + + return true; + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob: warns for a blob + /// would reject — unparseable JSON, no DataItem id under any of the + /// three accepted spellings, or a present-but-invalid coercion type (including the digits-only + /// numerically-serialized-enum shape). Empty when the parse accepts the blob, when it is blank, or + /// when it is not a JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver reads draws nothing. See the class remarks. + if (TryFromTagConfig(reference, out _, out _)) return warnings; + + MTConnectRawTagConfigDto? blob; + try + { + blob = JsonSerializer.Deserialize(reference, JsonOptions); + } + catch (JsonException) + { + warnings.Add("MTConnect TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + if (blob is null) + { + warnings.Add("MTConnect TagConfig is a JSON null — the tag binds no DataItem and will be skipped."); + return warnings; + } + + if (FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address) is null) + { + warnings.Add( + "MTConnect TagConfig names no DataItem id (expected 'fullName', 'dataItemId' or 'address') — " + + "the driver will skip this tag and it will read BadNodeIdUnknown."); + } + + var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); + if (authoredType is not null && !TryParseDataType(authoredType, out _)) + { + var field = string.IsNullOrWhiteSpace(blob.DriverDataType) ? "dataType" : "driverDataType"; + warnings.Add( + $"value '{authoredType}' for '{field}' is not a valid DriverDataType name; valid: " + + string.Join(", ", Enum.GetNames())); + } + + if (warnings.Count == 0) + { + warnings.Add( + "MTConnect TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + + return warnings; + } + + /// + /// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the + /// parse: accepts "8" as readily + /// as "Float64" and returns the member with that ordinal, so a numerically-serialized enum + /// (the repo-wide authoring trap) would otherwise coerce every value of that tag to a type nobody + /// chose, under Good quality. + /// + /// The authored type text. + /// The parsed type when this returns . + /// when is a defined member NAME. + private static bool TryParseDataType(string raw, out DriverDataType parsed) + { + parsed = default; + if (raw.All(char.IsAsciiDigit)) return false; + return Enum.TryParse(raw, ignoreCase: true, out parsed) && Enum.IsDefined(parsed); + } + + /// The first of that carries text, trimmed; else null. + /// The candidate values, in precedence order. + /// The first non-blank candidate, trimmed, or null. + private static string? FirstNonBlank(params string?[] candidates) + { + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim(); + } + + return null; + } + + /// + /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag. + /// Every field is optional — the blob is operator-authorable and reaches the driver through + /// several authoring surfaces that populate different subsets of it. + /// + /// + /// + /// Three accepted spellings of the DataItem id, tried in order: + /// fullName (the driver's own tag shape, and what the typed editor writes), + /// dataItemId (the protocol's own word for it, and the spelling an operator + /// hand-authoring the blob reaches for first), and address (what + /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the + /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three + /// costs two dictionary probes; rejecting two of them would make a tag committed from the + /// address picker silently unreadable. + /// + /// + /// Two accepted spellings of the coercion type, likewise: dataType — what + /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag + /// authored through the typed editor carries — and driverDataType, matching the + /// driver's own tags[] field name. Reading only one of them would make the editor + /// and the driver disagree about the type of every tag. + /// + /// + /// Kept separate from the driver's strict tags[] DTO rather than reusing it: there a + /// missing fullName is a config error worth failing the deployment over. A raw tag's + /// blob is authored elsewhere and its failure mode is per-tag, not per-driver. + /// + /// + private sealed class MTConnectRawTagConfigDto + { + public string? FullName { get; init; } + public string? DataItemId { get; init; } + public string? Address { get; init; } + public string? DriverDataType { get; init; } + public string? DataType { get; init; } + public bool? IsArray { get; init; } + public int? ArrayDim { get; init; } + public string? MtCategory { get; init; } + public string? MtType { get; init; } + public string? MtSubType { get; init; } + public string? Units { get; init; } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 6302aa9f..a666a27d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver { if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue; - if (!TryReadRawTag(entry, out var dataItemId, out var authored)) + if (!MTConnectTagDefinitionFactory.TryFromTagConfig(entry.TagConfig, out var dataItemId, out var authored)) { _skippedTags.Skip( entry.RawPath, @@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver return map.ToFrozenDictionary(StringComparer.Ordinal); } - /// - /// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares - /// (null when the blob declares no type and the caller must fall back). - /// - /// - /// when the blob is unreadable, names no DataItem id, or carries a - /// driverDataType that is not a — a present-but-invalid - /// type rejects the tag rather than silently defaulting, which would publish the Agent's text - /// as a Good value of the wrong type (the sibling drivers' strict-enum posture). - /// - private static bool TryReadRawTag( - RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored) - { - dataItemId = string.Empty; - authored = null; - - if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false; - - MTConnectRawTagConfigDto? blob; - try - { - blob = JsonSerializer.Deserialize(entry.TagConfig, JsonOptions); - } - catch (JsonException) - { - return false; - } - - if (blob is null) return false; - - var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address); - if (id is null) return false; - - dataItemId = id; - - // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed - // MTConnectTagConfigModel writes) — see the DTO remarks. - var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); - if (authoredType is null) return true; - - // A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as - // readily as "Float64" and returns the member with that ordinal, so a numerically-serialized - // enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a - // type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate - // refuses the same shape; this is the runtime half of that guard. - if (authoredType.All(char.IsAsciiDigit) - || !Enum.TryParse(authoredType, ignoreCase: true, out var parsed) - || !Enum.IsDefined(parsed)) - { - return false; - } - - authored = new MTConnectTagDefinition( - dataItemId, - parsed, - blob.IsArray ?? false, - blob.ArrayDim ?? 0, - blob.MtCategory, - blob.MtType, - blob.MtSubType, - blob.Units); - - return true; - } - /// /// The definition for a raw tag whose blob declared no type: the Agent's own /probe /// declaration when the model is in hand, else — the one @@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver return map.ToFrozenDictionary(StringComparer.Ordinal); } - /// The first of that carries text, trimmed; else null. - private static string? FirstNonBlank(params string?[] candidates) - { - foreach (var candidate in candidates) - { - if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim(); - } - - return null; - } - /// /// Parses an enum authored as a name. Config surfaces serialize enums as names /// project-wide; a numerically-serialized enum is the known trap that faults a driver at @@ -3735,51 +3659,6 @@ public sealed class MTConnectDriver public MTConnectWriteDto? Write { get; init; } } - /// - /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag. - /// Every field is optional — the blob is operator-authorable and reaches the driver through - /// several authoring surfaces that populate different subsets of it. - /// - /// - /// - /// Three accepted spellings of the DataItem id, tried in order: - /// fullName (the driver's own tag shape, and what the typed editor writes), - /// dataItemId (the protocol's own word for it, and the spelling an operator - /// hand-authoring the blob reaches for first), and address (what - /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the - /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three - /// costs two dictionary probes; rejecting two of them would make a tag committed from the - /// address picker silently unreadable. - /// - /// - /// Two accepted spellings of the coercion type, likewise: dataType — what - /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag - /// authored through the typed editor carries — and driverDataType, matching the - /// driver's own tags[] field name. Reading only one of them would make the editor - /// and the driver disagree about the type of every tag. - /// - /// - /// Kept separate from rather than reusing it: that DTO is - /// the strict tags[] surface, where a missing fullName is a config error - /// worth failing the deployment over. A raw tag's blob is authored elsewhere and its - /// failure mode is per-tag, not per-driver. - /// - /// - private sealed class MTConnectRawTagConfigDto - { - public string? FullName { get; init; } - public string? DataItemId { get; init; } - public string? Address { get; init; } - public string? DriverDataType { get; init; } - public string? DataType { get; init; } - public bool? IsArray { get; init; } - public int? ArrayDim { get; init; } - public string? MtCategory { get; init; } - public string? MtType { get; init; } - public string? MtSubType { get; init; } - public string? Units { get; init; } - } - /// One authored tag. DriverDataType stays a string — see . private sealed class MTConnectTagDto { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs index 776dfa40..d7371d86 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Opc.Ua; using Opc.Ua.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -119,37 +118,14 @@ public sealed class NamespaceMap foreach (var entry in rawTags) { if (string.IsNullOrWhiteSpace(entry.RawPath)) continue; - if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue; + // The blob read lives on OpcUaClientTagDefinitionFactory so the deploy-time TagConfig gate and + // this table build cannot disagree about what a usable nodeId is (G-7). + if (!OpcUaClientTagDefinitionFactory.TryReadNodeId(entry.TagConfig, out var nodeId)) continue; table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent); } return table; } - /// - /// Read the upstream node id string from an authored TagConfig blob's nodeId key - /// (camelCase, per the AdminUI OpcUaClientTagConfigModel; a legacy PascalCase NodeId - /// is also accepted). Never throws — a malformed blob yields . - /// - private static bool TryReadNodeId(string tagConfig, out string nodeId) - { - nodeId = string.Empty; - if (string.IsNullOrWhiteSpace(tagConfig)) return false; - try - { - using var doc = JsonDocument.Parse(tagConfig); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) return false; - if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n)) - && n.ValueKind == JsonValueKind.String) - { - var s = n.GetString(); - if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; } - } - } - catch (JsonException) { /* malformed blob — treat as unresolvable */ } - return false; - } - /// Number of namespaces captured. Index 0 is always the OPC UA core namespace. public int Count => _uris.Length; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs new file mode 100644 index 00000000..6f35ab84 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both +/// the runtime ('s RawPath → upstream node id table build) and the +/// deploy-time TagConfig gate (, deferment-register G-7). +/// +/// This read used to be private to . It moved here — unchanged — +/// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side +/// is touched, and the drift is silent, with the gate describing a shape the driver no longer +/// reads. now calls . +/// +/// +/// is never stricter than — it asks the +/// read for the verdict and only explains a rejection, so a blob the driver resolves can never draw +/// a warning calling it invalid. Nothing else in the blob is checked: the upstream node id is an +/// opaque reference string that the SDK re-binds against the live session, so whether it names a +/// node that exists is knowable only at connect time, not at deploy time. +/// +/// +public static class OpcUaClientTagDefinitionFactory +{ + /// + /// Read the upstream node id string from an authored TagConfig blob's nodeId key + /// (camelCase, per the AdminUI OpcUaClientTagConfigModel; a legacy PascalCase NodeId + /// is also accepted). Never throws — a malformed blob yields . + /// + /// The authored equipment-tag TagConfig JSON. + /// The upstream node id string when this returns . + /// when the blob carries a usable upstream node reference. + public static bool TryReadNodeId(string tagConfig, out string nodeId) + { + nodeId = string.Empty; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n)) + && n.ValueKind == JsonValueKind.String) + { + var s = n.GetString(); + if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; } + } + } + catch (JsonException) { /* malformed blob — treat as unresolvable */ } + return false; + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob: warns for a blob + /// would reject — unparseable JSON, a non-object root, or no usable + /// nodeId (absent, non-string, or blank). Such a tag is dropped from the driver's resolution + /// table and reads BadNodeIdInvalid forever, which before Gitea #541 was the quietest skip + /// of the twelve drivers — not even a log line. Empty when the blob resolves, is blank, or is not a + /// JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver resolves draws nothing. See the class remarks. + if (TryReadNodeId(reference, out _)) return warnings; + + try + { + using var doc = JsonDocument.Parse(reference); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + warnings.Add( + "OpcUaClient TagConfig root is not a JSON object — the tag binds no upstream node and " + + "will read BadNodeIdInvalid."); + return warnings; + } + } + catch (JsonException) + { + warnings.Add("OpcUaClient TagConfig is not valid JSON — the tag will not resolve (BadNodeIdInvalid)."); + return warnings; + } + + warnings.Add( + "OpcUaClient TagConfig has no usable 'nodeId' (a non-blank string holding the upstream " + + "nsu=…/ns=N;… reference) — the driver drops this tag from its resolution table and it reads " + + "BadNodeIdInvalid."); + return warnings; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs new file mode 100644 index 00000000..cf06e1d3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// Deploy-time inspection of a Sql raw tag's TagConfig blob (deferment-register G-7). The Sql +/// driver already owns its runtime parse in this assembly +/// (), so this type adds only the operator-facing +/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a +/// rejection. +/// +/// Why the verdict comes from the parser and not from a mirror. The one rule this gate must +/// never break is being stricter than the runtime: a blob the driver happily serves must not draw a +/// deploy warning calling it invalid. Running first and +/// returning empty on success makes that structural rather than a promise — a warning is reachable +/// only for a blob the driver would have refused (and then skipped silently, reading +/// BadNodeIdUnknown forever). +/// +/// +/// The class is named SqlTagDefinitionFactory to match the six sibling +/// *TagDefinitionFactory.Inspect entry points the deploy gate discovers by reflection +/// (EquipmentTagConfigInspectorCoverageTests); the mapping half of a "factory" already lives +/// on and is deliberately not duplicated here. +/// +/// +public static class SqlTagDefinitionFactory +{ + /// + /// The RawPath handed to the parser during inspection. The parser only copies it onto + /// and never validates it, so inspection is free to pass a + /// placeholder — the deploy gate is inspecting a blob, not a placed tag. + /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable warnings + /// for a blob would reject — an unparseable or + /// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid + /// model / type enum, the P3-deferred Query model, a missing table, or a + /// missing model-required field. Empty when the parser accepts the blob, when the blob is blank, or + /// when it is not a JSON object at all (the sibling factories' "not an equipment-tag TagConfig" + /// convention). Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver serves draws nothing. See the class remarks. + if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings; + + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("Sql TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + DescribeRejection(root, warnings); + } + catch (JsonException) + { + warnings.Add("Sql TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + // Backstop: the parser refused for a reason nothing above names (it also swallows FormatException). + // Saying so is worth more than reporting a clean blob the driver will silently skip. + if (warnings.Count == 0) + { + warnings.Add( + "Sql TagConfig was rejected by the driver's tag parser — the driver will skip this tag and it " + + "will read BadNodeIdUnknown."); + } + + return warnings; + } + + /// + /// Explains why refused an object-rooted blob, walking + /// its checks in the same order. Every branch here mirrors a return false in the parser — this + /// runs only after the parser has already refused, so it can never invent strictness of its own. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + // Recognition (parser: a "driver":"Sql" marker or a "model" discriminator). + var hasModel = root.TryGetProperty("model", out _); + var driver = ReadString(root, "driver"); + if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) + { + warnings.Add( + "Sql TagConfig carries neither a 'model' field nor 'driver':'Sql', so the driver does not " + + "recognise it as a Sql tag and will skip it (BadNodeIdUnknown)."); + return; + } + + // Strict enums: a typo'd value rejects the whole tag rather than silently defaulting. + var modelWarning = TagConfigJson.DescribeInvalidEnum(root, "model"); + if (modelWarning is not null) warnings.Add(modelWarning); + var typeWarning = TagConfigJson.DescribeInvalidEnum(root, "type"); + if (typeWarning is not null) warnings.Add(typeWarning); + if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run + + var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue); + if (model == SqlTagModel.Query) + { + warnings.Add( + "Sql tag model 'Query' is deferred (design §5.4 / P3) and is not served — the driver will " + + "skip this tag."); + return; + } + + if (string.IsNullOrWhiteSpace(ReadString(root, "table"))) + warnings.Add("Sql TagConfig has no 'table' — every Sql tag model requires one."); + + switch (model) + { + case SqlTagModel.KeyValue: + if (string.IsNullOrWhiteSpace(ReadString(root, "keyColumn"))) + warnings.Add("Sql KeyValue tag has no 'keyColumn'."); + if (string.IsNullOrWhiteSpace(ReadString(root, "valueColumn"))) + warnings.Add("Sql KeyValue tag has no 'valueColumn'."); + if (ReadString(root, "keyValue") is null) + warnings.Add("Sql KeyValue tag has no 'keyValue' (an empty string is allowed; an absent key is not)."); + break; + + case SqlTagModel.WideRow: + if (string.IsNullOrWhiteSpace(ReadString(root, "columnName"))) + warnings.Add("Sql WideRow tag has no 'columnName'."); + if (!HasRowSelector(root)) + { + warnings.Add( + "Sql WideRow tag has no usable 'rowSelector' — it needs a 'whereColumn'/'whereValue' " + + "pair or a 'topByTimestamp' column, or the driver cannot tell which row to read."); + } + + break; + + default: + break; + } + } + + /// + /// Whether a WideRow blob names a row the driver can select, mirroring the parser's + /// hasWherePair || topByTimestamp test (a where-pair needs BOTH keys, though + /// whereValue may legitimately be an empty string). + /// + /// The TagConfig root object. + /// when the blob names a selectable row. + private static bool HasRowSelector(JsonElement root) + { + if (!root.TryGetProperty("rowSelector", out var sel) || sel.ValueKind != JsonValueKind.Object) + return false; + var hasWherePair = !string.IsNullOrWhiteSpace(ReadString(sel, "whereColumn")) + && HasScalar(sel, "whereValue"); + return hasWherePair || !string.IsNullOrWhiteSpace(ReadString(sel, "topByTimestamp")); + } + + /// Reads a string-valued property; null when absent or not a JSON string (as the parser does). + /// The object to read from. + /// The property name. + /// The string value, or null. + private static string? ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + /// Whether a property is present as a scalar the parser's ReadScalar would capture. + /// The object to read from. + /// The property name. + /// when the property is a string, number or boolean. + private static bool HasScalar(JsonElement o, string name) + => o.TryGetProperty(name, out var e) + && e.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False; +} From 1469fbb4d164b88e2e8505d927f45394debde269 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 21:14:49 -0400 Subject: [PATCH 2/2] feat(deploy): wire the four G-7 inspectors into the deploy-time TagConfig gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Sql / MTConnect / Calculation / OpcUaClient Contracts ProjectReferences to ControlPlane and their entries to EquipmentTagConfigInspector, closing the G-7 asymmetry: the deploy gate now checks every driver the AdminUI checks. Galaxy is the sole remaining unmapped type and is so by design — its dotted tag_name.AttributeName reference has no typed shape, and the AdminUI has no typed editor for it either, so the two gates agree. Adding the ProjectReference is what makes EquipmentTagConfigInspectorCoverageTests demand a map entry (its sweep scans the output directory, so an unreferenced Contracts assembly is invisible to it). Falsified: deleting the Sql entry fails Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map with ["SqlTagDefinitionFactory"]; restored. Tests: three cases per driver in EquipmentTagConfigInspectorTests (canonical valid config warns nothing / present-but-invalid field draws a warning naming it / broken JSON warns without throwing), plus a Calculation control pinning that the two shapes the runtime accepts and the editor refuses draw nothing. OpcUaClient moved out of the unmapped-driver theory. New TagConfigAuthoringDeployParityTests (in AdminUI.Tests, the only suite that sees both gates) pins the direction that matters: a config the AdminUI model's own ToJson produces and Validate accepts draws no deploy warning. Its falsifiability control — the same driver types warning about a blob the editor refuses — caught a pre-existing, unrelated authoring-side gap: MTConnectTagConfigModel reads dataType leniently, so a misspelled name loads as String and saves clean while the strict runtime rejects it. Left alone (rewriting the editors is out of scope); the control uses the digits-only shape both gates refuse, and the case is documented inline. --- .../EquipmentTagConfigInspector.cs | 29 +++- .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 12 ++ .../TagConfigAuthoringDeployParityTests.cs | 146 ++++++++++++++++ ...quipmentTagConfigInspectorCoverageTests.cs | 10 +- .../EquipmentTagConfigInspectorTests.cs | 163 +++++++++++++++++- 5 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index 7e8a25e5..b17ad450 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -1,10 +1,14 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.Calculation; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; +using ZB.MOM.WW.OtOpcUa.Driver.MTConnect; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; @@ -20,13 +24,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; /// *.Contracts assemblies — so a driver that gains an inspector cannot be silently left out. This /// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous /// hand-maintained list had drifted (MQTT shipped an Inspect and was never added). -/// Residual gap, deliberately recorded rather than papered over. Galaxy, OpcUaClient, Sql, -/// MTConnect and Calculation have no Inspect in a Contracts assembly, so this deploy-time -/// gate cannot check them — while the AdminUI's TagConfigValidator DOES cover all but Galaxy via -/// its *TagConfigModel.Validate() models, which live in the AdminUI project and cannot be -/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked, -/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an -/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7. +/// The G-7 gap is CLOSED (2026-08-09). Sql, MTConnect, Calculation and OpcUaClient used to +/// have no Inspect in a Contracts assembly, so this gate was blind to them while the AdminUI's +/// TagConfigValidator checked them at authoring — the asymmetry ran the wrong way, since config +/// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side +/// *TagDefinitionFactory.Inspect, wired below. Three of the four wrap the driver's own runtime +/// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter +/// than the runtime: SqlTagDefinitionFactory wraps SqlEquipmentTagParser, and the +/// MTConnect / OpcUaClient / Calculation blob readers were MOVED into their Contracts assemblies (the +/// drivers now call them) rather than copied. +/// Galaxy is the sole remaining unmapped type, deliberately. Its tag reference is a dotted +/// tag_name.AttributeName authored through the Galaxy address picker, with no typed shape to +/// check — the AdminUI has no typed editor for it either (it uses the raw-JSON textarea), so this is +/// symmetric rather than a gap. It gains an entry here if it ever gains a Contracts-side parser. /// public static class EquipmentTagConfigInspector { @@ -40,6 +50,11 @@ public static class EquipmentTagConfigInspector [DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect, [DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect, [DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect, + // G-7 (2026-08-09): the four the gate used to be blind to. + [DriverTypeNames.Sql] = SqlTagDefinitionFactory.Inspect, + [DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect, + [DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect, + [DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect, }; /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 05f6a470..e4f43208 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -50,6 +50,18 @@ + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs new file mode 100644 index 00000000..7dcac8ce --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs @@ -0,0 +1,146 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Deferment-register G-7 pin: the AdminUI's authoring-time and the +/// deploy-time must agree in the direction that matters — +/// anything the modal lets an operator save must sail through the deploy gate. +/// +/// The reverse direction is deliberately NOT asserted, and must not be: the deploy gate follows the +/// runtime parser, which is lenient in places the editor is opinionated (a calc tag with no +/// trigger, a sub-50 ms timer). Config authored by CSV import or API meets only the deploy gate, so +/// a gate stricter than the runtime would block deployments the driver would have served — and the +/// gate's warnings can be configured (TagConfigValidationMode.Error) into blocking errors. +/// +/// +/// The configs are produced by the models' own ToJson() rather than hand-written JSON, +/// which is the only way this proves anything about the real authoring path: a hand-written blob is +/// a guess at what the editor writes, and the editors normalise keys on save (MTConnect collapses +/// dataItemId/address onto fullName; Sql rewrites its enums as names). This +/// lives in AdminUI.Tests because it is the only suite that can see both sides — the models are +/// internal to the AdminUI project, which references ControlPlane. +/// +/// +public sealed class TagConfigAuthoringDeployParityTests +{ + /// A Sql tag as the typed editor saves it: both the editor and the gate accept it. + [Fact] + public void Sql_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new SqlTagConfigModel + { + Model = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.SqlTagModel.KeyValue, + Table = "dbo.TagValues", + KeyColumn = "TagName", + KeyValue = "Line1.Speed", + ValueColumn = "Value", + Type = DriverDataType.Float32, + }; + AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson()); + } + + /// The WideRow shape too — its row selector is the half of the parser a KeyValue tag never + /// exercises, and the editor and the parser have to agree that a where-pair needs BOTH keys. + [Fact] + public void Sql_wideRow_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new SqlTagConfigModel + { + Model = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.SqlTagModel.WideRow, + Table = "dbo.LineSnapshot", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { WhereColumn = "LineId", WhereValue = "L1" }, + }; + AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson()); + } + + [Fact] + public void MTConnect_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new MTConnectTagConfigModel + { + FullName = "Xact", + DataType = DriverDataType.Float64, + MtCategory = "SAMPLE", + MtType = "POSITION", + Units = "MILLIMETER", + }; + AssertParity(DriverTypeNames.MTConnect, model.Validate(), model.ToJson()); + } + + [Fact] + public void Calculation_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new CalculationTagConfigModel + { + ScriptId = "8f1c2f6e-0000-4000-8000-000000000001", + ChangeTriggered = true, + TimerIntervalMs = 5000, + }; + AssertParity(DriverTypeNames.Calculation, model.Validate(), model.ToJson()); + } + + /// Change-trigger only — the editor omits timerIntervalMs entirely rather than writing a + /// null or a zero, and an absent key must not read as a discarded value at the gate. + [Fact] + public void Calculation_change_triggered_only_config_draws_no_deploy_warning() + { + var model = new CalculationTagConfigModel { ScriptId = "S1", ChangeTriggered = true }; + AssertParity(DriverTypeNames.Calculation, model.Validate(), model.ToJson()); + } + + [Fact] + public void OpcUaClient_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new OpcUaClientTagConfigModel { NodeId = "nsu=urn:plc:sim;s=Line1.Speed" }; + AssertParity(DriverTypeNames.OpcUaClient, model.Validate(), model.ToJson()); + } + + /// + /// Falsifiability control. Every assertion above would also pass against a build where the four G-7 + /// inspectors were never wired — an unmapped driver type returns no warnings for anything. This one + /// proves the gate is actually looking: the same driver types must warn about a blob the editor + /// itself would refuse. + /// + [Theory] + [InlineData(DriverTypeNames.Sql, """{"model":"KeyValu","table":"dbo.T"}""")] + // Note the MTConnect case is the DIGITS-only dataType, not a spelling typo: MTConnectTagConfigModel + // reads dataType leniently (an unrecognised NAME silently loads as String, and a save rewrites it), so + // "Strng" is accepted at authoring while the strict runtime rejects it — a pre-existing + // authoring-weaker-than-runtime gap in the editor, untouched here and out of G-7's scope. It does not + // affect the parity direction this file pins, because a blob saved through the modal always carries a + // valid NAME. + [InlineData(DriverTypeNames.MTConnect, """{"fullName":"avail","dataType":"8"}""")] + [InlineData(DriverTypeNames.Calculation, """{"changeTriggered":true}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"nodeId":""}""")] + public void The_same_driver_types_do_warn_about_a_config_the_editor_refuses(string driverType, string tagConfig) + { + TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty( + $"{driverType}: the authoring gate is expected to refuse this blob — if it accepts it, the " + + "control below is testing the wrong thing."); + EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldNotBeEmpty( + $"{driverType}: the deploy gate accepted a blob the modal refuses, so config arriving by CSV " + + "import or API is unchecked — the exact G-7 asymmetry."); + } + + /// Asserts the editor accepted the config (so the parity claim is not vacuous) and that the + /// deploy gate then found nothing to say about it. + /// The canonical driver-type string both gates dispatch on. + /// What the model's own Validate() returned. + /// The config the model serialised. + private static void AssertParity(string driverType, string? validationError, string tagConfig) + { + validationError.ShouldBeNull( + $"{driverType}: the model must accept this config, or the parity assertion below proves nothing."); + TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( + $"{driverType}: the dispatched authoring validator must accept what the model itself accepts."); + EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldBeEmpty( + $"{driverType}: the deploy gate must never be stricter than the authoring gate — a config the " + + "operator was allowed to save cannot draw a deploy warning."); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs index d453cc3e..d6b7aed9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs @@ -78,9 +78,13 @@ public sealed class EquipmentTagConfigInspectorCoverageTests /// project-referenced assembly to the output regardless of IL usage, so the directory is the honest /// source. /// Boundary, stated so it is not mistaken for more: this sees the Contracts assemblies - /// ControlPlane project-references. A driver whose Contracts project is not referenced at all (Sql, - /// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act - /// that makes this guard start demanding a map entry. + /// ControlPlane project-references. A driver whose Contracts project is not referenced at all is + /// invisible here — adding the reference is itself the act that makes this guard start demanding a + /// map entry. That was the whole shape of deferment-register G-7: Sql, MTConnect, Calculation and + /// OpcUaClient were unreferenced and therefore unseen. All four were referenced and wired on + /// 2026-08-09, so the only driver left outside this sweep is Galaxy, which ships no Contracts-side + /// parser at all (its dotted tag_name.AttributeName reference has no typed shape to check, and + /// the AdminUI has no typed editor for it either). /// private static IReadOnlyList DiscoverInspectableFactories() => Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll") diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs index b99a5052..0a9b19da 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs @@ -5,7 +5,11 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// 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. +/// route to their parser's Inspect; the one unmapped driver (Galaxy) skips; keys are case-insensitive. +/// The Sql / MTConnect / Calculation / OpcUaClient cases below landed with deferment-register G-7 +/// (2026-08-09), which gave those four a Contracts-side Inspect. Each follows the same three-case +/// shape as its siblings: a canonical valid config warns about nothing, a present-but-invalid field draws a +/// warning that NAMES the field, and structurally broken JSON draws a warning without throwing. public sealed class EquipmentTagConfigInspectorTests { [Fact] @@ -25,9 +29,11 @@ public sealed class EquipmentTagConfigInspectorTests .ShouldBeEmpty(); } + // Galaxy is the last unmapped type and is so by design (no Contracts-side parser, and no typed AdminUI + // editor either). "Unknown" is the never-heard-of-it control. OpcUaClient used to sit in this list — + // G-7 moved it out; the pair of tests below is the pin that it cannot silently drift back. [Theory] [InlineData("GalaxyMxGateway")] - [InlineData("OpcUaClient")] [InlineData("Unknown")] public void Unmapped_driver_types_are_skipped(string driverType) { @@ -35,16 +41,169 @@ public sealed class EquipmentTagConfigInspectorTests EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse(); } + /// G-7: the four drivers the deploy gate used to be blind to are dispatched now. Named + /// explicitly rather than derived, so a map entry deleted alongside its Contracts reference (which would + /// take the reflection coverage guard dark with it) still fails something. + [Theory] + [InlineData("Sql")] + [InlineData("MTConnect")] + [InlineData("Calculation")] + [InlineData("OpcUaClient")] + public void The_G7_driver_types_are_mapped(string driverType) + => EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(driverType); + [Theory] [InlineData("modbus")] [InlineData("FOCAS")] [InlineData("focas")] [InlineData("twincat")] + [InlineData("sql")] + [InlineData("mtconnect")] + [InlineData("calculation")] + [InlineData("opcuaclient")] public void Driver_type_matching_is_case_insensitive(string driverType) { EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(); } + // ---- G-7: Sql ---- + + [Fact] + public void Clean_sql_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "Sql", + """{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Value"}""") + .ShouldBeEmpty(); + + [Fact] + public void Sql_typo_in_model_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Sql", + """{"model":"KeyValu","table":"dbo.T","keyColumn":"k","keyValue":"v","valueColumn":"val"}"""); + warnings.ShouldContain(w => w.Contains("model") && w.Contains("KeyValu")); + } + + [Fact] + public void Sql_deferred_query_model_is_reported() + => EquipmentTagConfigInspector.Inspect("Sql", """{"model":"Query","table":"dbo.T"}""") + .ShouldContain(w => w.Contains("Query")); + + [Fact] + public void Sql_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("Sql", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: MTConnect ---- + + [Fact] + public void Clean_mtconnect_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "MTConnect", """{"fullName":"avail","dataType":"String"}""") + .ShouldBeEmpty(); + + [Fact] + public void MTConnect_typo_in_dataType_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "MTConnect", """{"fullName":"avail","dataType":"Strng"}"""); + warnings.ShouldContain(w => w.Contains("dataType") && w.Contains("Strng")); + } + + /// The numerically-serialized-enum trap: Enum.TryParse takes "8" as readily as a name, + /// so a digits-only value must be reported rather than silently becoming member 8. + [Fact] + public void MTConnect_numeric_dataType_is_reported() + => EquipmentTagConfigInspector.Inspect("MTConnect", """{"fullName":"avail","dataType":"8"}""") + .ShouldContain(w => w.Contains("dataType")); + + [Fact] + public void MTConnect_config_naming_no_dataitem_is_reported() + => EquipmentTagConfigInspector.Inspect("MTConnect", """{"dataType":"String"}""") + .ShouldContain(w => w.Contains("fullName")); + + [Fact] + public void MTConnect_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("MTConnect", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: Calculation ---- + + [Fact] + public void Clean_calculation_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "Calculation", """{"scriptId":"S1","changeTriggered":true,"timerIntervalMs":5000}""") + .ShouldBeEmpty(); + + [Fact] + public void Calculation_non_boolean_changeTriggered_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Calculation", """{"scriptId":"S1","changeTriggered":"false"}"""); + warnings.ShouldContain(w => w.Contains("changeTriggered")); + } + + [Fact] + public void Calculation_non_numeric_timer_names_the_field() + => EquipmentTagConfigInspector.Inspect("Calculation", """{"scriptId":"S1","timerIntervalMs":"5000"}""") + .ShouldContain(w => w.Contains("timerIntervalMs")); + + [Fact] + public void Calculation_config_naming_no_script_is_reported() + => EquipmentTagConfigInspector.Inspect("Calculation", """{"changeTriggered":true}""") + .ShouldContain(w => w.Contains("scriptId")); + + /// Falsification control for the never-stricter-than-the-runtime rule: the calc driver ACCEPTS + /// a tag with no trigger at all (it simply never computes) and honours a sub-50 ms timer the AdminUI + /// editor refuses, so the deploy gate — whose warnings can be configured into blocking errors — must + /// stay silent on both. + [Theory] + [InlineData("""{"scriptId":"S1","changeTriggered":false}""")] + [InlineData("""{"scriptId":"S1","changeTriggered":false,"timerIntervalMs":10}""")] + public void Calculation_shapes_the_runtime_accepts_draw_no_warning(string tagConfig) + => EquipmentTagConfigInspector.Inspect("Calculation", tagConfig).ShouldBeEmpty(); + + [Fact] + public void Calculation_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("Calculation", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: OpcUaClient ---- + + [Fact] + public void Clean_opcuaclient_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "OpcUaClient", """{"nodeId":"nsu=urn:plc:sim;s=Line1.Speed"}""") + .ShouldBeEmpty(); + + // The OpcUaClient blob carries no enum, so its present-but-unusable case is a nodeId that is blank or + // not a string — both of which the driver drops from its resolution table without a word. + [Theory] + [InlineData("""{"nodeId":""}""")] + [InlineData("""{"nodeId":" "}""")] + [InlineData("""{"nodeId":123}""")] + [InlineData("""{"tagName":"Line1.Speed"}""")] + public void OpcUaClient_unusable_nodeId_names_the_field(string tagConfig) + => EquipmentTagConfigInspector.Inspect("OpcUaClient", tagConfig) + .ShouldContain(w => w.Contains("nodeId")); + + [Fact] + public void OpcUaClient_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("OpcUaClient", "{not json")); + warnings.ShouldNotBeEmpty(); + } + [Fact] public void Focas_writable_true_dispatches_read_only_warning() {