merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate

This commit is contained in:
Joseph Doherty
2026-08-09 21:16:16 -04:00
15 changed files with 1057 additions and 197 deletions
@@ -0,0 +1,161 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The four fields a calc tag's <c>TagConfig</c> blob carries, as
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> read them. Deliberately not the
/// driver's <c>CalculationTagDefinition</c>: 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.
/// </summary>
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity that computes the tag; never blank.</param>
/// <param name="ScriptSource">The resolved C# script source; empty when the blob carried none.</param>
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes (default <see langword="true"/>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or <see langword="null"/> for change-trigger only.</param>
public readonly record struct CalculationTagConfigFields(
string ScriptId,
string ScriptSource,
bool ChangeTriggered,
int? TimerIntervalMs);
/// <summary>
/// The authored-raw-tag reader for the <c>Calculation</c> pseudo-driver: one authority, consumed by both
/// the runtime (<c>CalculationTagDefinition.TryFromTagConfig</c>, which adds the RawPath identity and the
/// script's dependency refs on top) and the deploy-time TagConfig gate (<see cref="Inspect"/>,
/// deferment-register G-7).
/// <para>
/// <b>The JSON reading moved here from the driver; the definition-building did not.</b> Building a
/// <c>CalculationTagDefinition</c> needs <c>EquipmentScriptPaths.ExtractDependencyRefs</c>, 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 <i>blob</i> says is read here, once.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadTagConfig"/>.</b> 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 — <c>changeTriggered</c> that
/// is not a JSON boolean (read as <see langword="true"/>) and <c>timerIntervalMs</c> that is not a
/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently
/// defaulted" warning class.
/// </para>
/// <para>
/// <b>What it deliberately does NOT warn about:</b> a tag with no trigger at all
/// (<c>changeTriggered:false</c> and no timer). The AdminUI's <c>CalculationTagConfigModel.Validate</c>
/// rejects that shape and the runtime accepts it, and this gate follows the <i>runtime</i> — 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.
/// </para>
/// </summary>
public static class CalculationTagDefinitionFactory
{
/// <summary>
/// Reads a calc tag's <c>TagConfig</c> blob
/// (<c>{ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }</c>). Returns
/// <see langword="false"/> — never throws — only when the blob is unreadable, is not a JSON object,
/// or names no <c>scriptId</c>: a tag with no identifiable script cannot be a calc tag. A present
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still reads (the driver logs it and the
/// tag never computes).
/// </summary>
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
/// <param name="fields">The read fields when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob names a script.</returns>
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;
}
}
/// <summary>
/// Deploy-time inspection of a calc tag's <c>TagConfig</c> blob. Warns for a blob
/// <see cref="TryReadTagConfig"/> rejects (unparseable JSON, a non-object root, or no usable
/// <c>scriptId</c>), and for a present-but-discarded <c>changeTriggered</c> / <c>timerIntervalMs</c>
/// — 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.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;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
</PropertyGroup>
<!-- The calc-tag TagConfig reader, shared by the driver (which turns it into a
CalculationTagDefinition) and the deploy-time TagConfig gate (G-7), mirroring
Driver.MTConnect.Contracts / Driver.Modbus.Contracts.
Deliberately reference-free, unlike its siblings: a calc tag's blob is
{ scriptId, scriptSource, changeTriggered, timerIntervalMs } — strings, a bool and an int, with
no DriverDataType or other Core.Abstractions type in it. Carrying the leaf reference anyway
would be dead weight that reads as a dependency. Add it the moment a field needs it. -->
</Project>
@@ -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;
}
@@ -19,6 +19,9 @@
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
owns DataValueSnapshot. -->
<!-- The calc tag's TagConfig reader (CalculationTagDefinitionFactory), shared with the deploy-time
TagConfig gate so the two cannot drift (G-7). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
@@ -0,0 +1,230 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime
/// (<c>MTConnectDriver</c>'s <c>RawPath → definition</c> table build) and the deploy-time TagConfig gate
/// (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This parse used to be private to <c>MTConnectDriver</c>.</b> 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
/// <see cref="TryFromTagConfig"/> where it called its own copy.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryFromTagConfig"/></b> — 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.
/// </para>
/// </summary>
public static class MTConnectTagDefinitionFactory
{
/// <summary>
/// Reader options, mirroring <c>MTConnectDriver</c>'s config reader (from which this moved). Note
/// there is deliberately <b>no</b> <c>JsonStringEnumConverter</c>: the type-carrying field stays a
/// <c>string?</c> and is parsed by name below, which is the project-wide guard against the
/// numerically-serialized-enum trap.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>
/// Reads one authored raw tag's <c>TagConfig</c> blob: the DataItem id it binds, and the definition
/// its blob declares (<see langword="null"/> when the blob declares no type and the caller must fall
/// back to the Agent's <c>/probe</c> declaration). Never throws.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="dataItemId">The Agent DataItem id the tag binds, when this returns <see langword="true"/>.</param>
/// <param name="authored">The definition the blob declared, or <see langword="null"/> when it declared no type.</param>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c>/<c>dataType</c> that is not a <see cref="DriverDataType"/> — 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).
/// </returns>
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<MTConnectRawTagConfigDto>(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;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryFromTagConfig"/> 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.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<MTConnectRawTagConfigDto>(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<DriverDataType>()));
}
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;
}
/// <summary>
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
/// as <c>"Float64"</c> 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.
/// </summary>
/// <param name="raw">The authored type text.</param>
/// <param name="parsed">The parsed type when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="raw"/> is a defined member NAME.</returns>
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);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
/// <param name="candidates">The candidate values, in precedence order.</param>
/// <returns>The first non-blank candidate, trimmed, or null.</returns>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// A raw tag's <c>TagConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from the driver's strict <c>tags[]</c> DTO rather than reusing it: there a
/// missing <c>fullName</c> 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.
/// </para>
/// </remarks>
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; }
}
}
@@ -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);
}
/// <summary>
/// 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).
/// </summary>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — 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).
/// </returns>
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<MTConnectRawTagConfigDto>(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<DriverDataType>(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;
}
/// <summary>
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
@@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// Parses an enum authored as a <b>name</b>. 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; }
}
/// <summary>
/// A raw tag's <c>TagConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> 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.
/// </para>
/// </remarks>
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; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
private sealed class MTConnectTagDto
{
@@ -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;
}
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
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;
}
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
public int Count => _uris.Length;
@@ -0,0 +1,95 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both
/// the runtime (<see cref="NamespaceMap"/>'s <c>RawPath → upstream node id</c> table build) and the
/// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This read used to be private to <see cref="NamespaceMap"/>.</b> 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. <see cref="NamespaceMap"/> now calls <see cref="TryReadNodeId"/>.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — 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.
/// </para>
/// </summary>
public static class OpcUaClientTagDefinitionFactory
{
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="nodeId">The upstream node id string when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob carries a usable upstream node reference.</returns>
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;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryReadNodeId"/> would reject — unparseable JSON, a non-object root, or no usable
/// <c>nodeId</c> (absent, non-string, or blank). Such a tag is dropped from the driver's resolution
/// table and reads <c>BadNodeIdInvalid</c> 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.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;
}
}
@@ -0,0 +1,183 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Deploy-time inspection of a Sql raw tag's <c>TagConfig</c> blob (deferment-register G-7). The Sql
/// driver already owns its runtime parse in this assembly
/// (<see cref="SqlEquipmentTagParser.TryParse"/>), so this type adds <b>only</b> the operator-facing
/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a
/// rejection.
/// <para>
/// <b>Why the verdict comes from the parser and not from a mirror.</b> 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 <see cref="SqlEquipmentTagParser.TryParse"/> 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
/// <c>BadNodeIdUnknown</c> forever).
/// </para>
/// <para>
/// The class is named <c>SqlTagDefinitionFactory</c> to match the six sibling
/// <c>*TagDefinitionFactory.Inspect</c> entry points the deploy gate discovers by reflection
/// (<c>EquipmentTagConfigInspectorCoverageTests</c>); the mapping half of a "factory" already lives
/// on <see cref="SqlEquipmentTagParser"/> and is deliberately not duplicated here.
/// </para>
/// </summary>
public static class SqlTagDefinitionFactory
{
/// <summary>
/// The RawPath handed to the parser during inspection. The parser only copies it onto
/// <see cref="SqlTagDefinition.Name"/> and never validates it, so inspection is free to pass a
/// placeholder — the deploy gate is inspecting a blob, not a placed tag.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable warnings
/// for a blob <see cref="SqlEquipmentTagParser.TryParse"/> would reject — an unparseable or
/// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid
/// <c>model</c> / <c>type</c> enum, the P3-deferred <c>Query</c> model, a missing <c>table</c>, 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.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;
}
/// <summary>
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
/// runs only after the parser has already refused, so it can never invent strictness of its own.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> 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<SqlTagModel>(root, "model");
if (modelWarning is not null) warnings.Add(modelWarning);
var typeWarning = TagConfigJson.DescribeInvalidEnum<DriverDataType>(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;
}
}
/// <summary>
/// Whether a WideRow blob names a row the driver can select, mirroring the parser's
/// <c>hasWherePair || topByTimestamp</c> test (a where-pair needs BOTH keys, though
/// <c>whereValue</c> may legitimately be an empty string).
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <returns><see langword="true"/> when the blob names a selectable row.</returns>
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"));
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string (as the parser does).</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns>The string value, or null.</returns>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>Whether a property is present as a scalar the parser's <c>ReadScalar</c> would capture.</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns><see langword="true"/> when the property is a string, number or boolean.</returns>
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;
}