From 2947a2b9e208550b14f888f18fa3375e497f3715 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 21:14:32 -0400 Subject: [PATCH] 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; +}