merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate
This commit is contained in:
+161
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
|
||||
+3
@@ -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"/>
|
||||
|
||||
+230
@@ -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;
|
||||
|
||||
|
||||
+95
@@ -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;
|
||||
}
|
||||
+22
-7
@@ -1,10 +1,14 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
@@ -20,13 +24,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
/// <c>*.Contracts</c> assemblies — so a driver that gains an inspector cannot be silently left out. This
|
||||
/// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous
|
||||
/// hand-maintained list had drifted (MQTT shipped an <c>Inspect</c> and was never added).</para>
|
||||
/// <para><b>Residual gap, deliberately recorded rather than papered over.</b> Galaxy, OpcUaClient, Sql,
|
||||
/// MTConnect and Calculation have <b>no</b> <c>Inspect</c> in a Contracts assembly, so this deploy-time
|
||||
/// gate cannot check them — while the AdminUI's <c>TagConfigValidator</c> DOES cover all but Galaxy via
|
||||
/// its <c>*TagConfigModel.Validate()</c> models, which live in the AdminUI project and cannot be
|
||||
/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked,
|
||||
/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an
|
||||
/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.</para>
|
||||
/// <para><b>The G-7 gap is CLOSED (2026-08-09).</b> Sql, MTConnect, Calculation and OpcUaClient used to
|
||||
/// have no <c>Inspect</c> in a Contracts assembly, so this gate was blind to them while the AdminUI's
|
||||
/// <c>TagConfigValidator</c> checked them at authoring — the asymmetry ran the wrong way, since config
|
||||
/// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side
|
||||
/// <c>*TagDefinitionFactory.Inspect</c>, wired below. Three of the four wrap the driver's own runtime
|
||||
/// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter
|
||||
/// than the runtime: <c>SqlTagDefinitionFactory</c> wraps <c>SqlEquipmentTagParser</c>, and the
|
||||
/// MTConnect / OpcUaClient / Calculation blob readers were MOVED into their Contracts assemblies (the
|
||||
/// drivers now call them) rather than copied.</para>
|
||||
/// <para><b>Galaxy is the sole remaining unmapped type, deliberately.</b> Its tag reference is a dotted
|
||||
/// <c>tag_name.AttributeName</c> authored through the Galaxy address picker, with no typed shape to
|
||||
/// check — the AdminUI has no typed editor for it either (it uses the raw-JSON textarea), so this is
|
||||
/// symmetric rather than a gap. It gains an entry here if it ever gains a Contracts-side parser.</para>
|
||||
/// </summary>
|
||||
public static class EquipmentTagConfigInspector
|
||||
{
|
||||
@@ -40,6 +50,11 @@ public static class EquipmentTagConfigInspector
|
||||
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect,
|
||||
// G-7 (2026-08-09): the four the gate used to be blind to.
|
||||
[DriverTypeNames.Sql] = SqlTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
|
||||
};
|
||||
|
||||
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<!-- G-7: the four drivers the deploy gate used to be blind to, while the AdminUI's
|
||||
TagConfigValidator checked them. Adding the reference is what makes
|
||||
EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans
|
||||
the output directory, so an unreferenced Contracts assembly is invisible to it.
|
||||
Note OpcUaClient.Contracts brings OPCFoundation.NetStandard.Opc.Ua.Client transitively: its
|
||||
NamespaceMap re-binds a stable nsu= reference against a live session. Every host that loads
|
||||
ControlPlane (Host, AdminUI) already carries the OPC UA stack, so this adds no new deployment
|
||||
surface. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user