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; +}