Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson readers, per-driver Inspect() + writable key, FOCAS capability pre-flight, Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396. Build clean.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// SINGLE SOURCE OF TRUTH for extracting + normalizing a <c>Device</c>'s connection host from its
|
||||
/// schemaless <c>DeviceConfig</c> JSON. Shared by the live-compose seam (<c>AddressSpaceComposer</c>),
|
||||
/// the artifact-decode seam (<c>DeploymentArtifact</c>), and the FixedTree-partition path
|
||||
/// (<c>DriverHostActor</c>) so an <c>EquipmentNode.DeviceHost</c> and a driver-discovered device-host
|
||||
/// folder segment for the same device compare byte-equal. Ported verbatim from the historical
|
||||
/// <c>AddressSpaceComposer.TryExtractDeviceHost</c> / <c>NormalizeDeviceHost</c>.
|
||||
/// </summary>
|
||||
public static class DeviceConfigIntent
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract a <c>Device</c>'s connection host from its schemaless <c>DeviceConfig</c> JSON: the
|
||||
/// top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>). Returns
|
||||
/// <see langword="null"/> when the config is blank, not a JSON object, has no string
|
||||
/// <c>HostAddress</c>, or the value is blank/whitespace. Never throws. The returned host is
|
||||
/// deterministically normalized via <see cref="NormalizeHost"/> (trim + lower-case).
|
||||
/// </summary>
|
||||
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
|
||||
/// <returns>The normalized device host, or <see langword="null"/> when absent/blank/unparseable.</returns>
|
||||
public static string? TryExtractHost(string? deviceConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(deviceConfigJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
|
||||
|| hostEl.ValueKind != JsonValueKind.String) return null;
|
||||
var raw = hostEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
return NormalizeHost(raw);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single normalization authority for a device host: trims surrounding whitespace and
|
||||
/// lower-cases (invariant). Idempotent (an already-normalized value is unchanged).
|
||||
/// </summary>
|
||||
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
|
||||
/// <returns>The normalized host (trimmed + lower-cased).</returns>
|
||||
public static string NormalizeHost(string host) => host.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-driver platform intents parsed from a Tag's schemaless <c>TagConfig</c> JSON.
|
||||
/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
|
||||
/// (<c>AddressSpaceComposer</c>), the artifact-decode seam (<c>DeploymentArtifact</c>), the draft
|
||||
/// gate (<c>DraftValidator</c>), and the walker (<c>EquipmentNodeWalker</c>). One
|
||||
/// <see cref="JsonDocument.Parse(string)"/> per blob. Never throws.
|
||||
/// <para>Two deliberate variations are carried on the record so every seam can be served from one
|
||||
/// parse:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="FullName"/> is the driver-side wire reference: the top-level
|
||||
/// <c>FullName</c> string when present, else the RAW blob (an equipment tag's TagConfig JSON
|
||||
/// <em>is</em> its wire reference for the six protocol drivers). <c>Parse(null)</c> ⇒
|
||||
/// <c>FullName == ""</c>.</description></item>
|
||||
/// <item><description><see cref="ExplicitFullName"/> is the <c>FullName</c> property ONLY when
|
||||
/// present-and-string, else <see langword="null"/> — the DraftValidator Galaxy-rule semantics
|
||||
/// (it wants the explicit reference, not the raw-blob fallback).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="FullName">Top-level <c>FullName</c> string, else the raw blob (wire-reference fallback).</param>
|
||||
/// <param name="ExplicitFullName">The <c>FullName</c> property when present-and-string, else <see langword="null"/>.</param>
|
||||
/// <param name="Alarm">The optional native-alarm intent parsed from the <c>alarm</c> object; <see langword="null"/> ⇒ plain value variable.</param>
|
||||
/// <param name="IsHistorized"><c>isHistorized</c> — true only when the value is a bool <c>true</c>.</param>
|
||||
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
|
||||
/// <param name="IsArray"><c>isArray</c> bool.</param>
|
||||
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
|
||||
public sealed record TagConfigIntent(
|
||||
string FullName,
|
||||
string? ExplicitFullName,
|
||||
TagAlarmIntent? Alarm,
|
||||
bool IsHistorized,
|
||||
string? HistorianTagname,
|
||||
bool IsArray,
|
||||
uint? ArrayLength)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
|
||||
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
|
||||
/// <see cref="FullName"/> = the raw blob (or "" for null). Semantics are ported verbatim from the
|
||||
/// historical <c>AddressSpaceComposer.Extract*</c> statics (the canonical copy).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed intents; never <see langword="null"/>.</returns>
|
||||
public static TagConfigIntent Parse(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig))
|
||||
return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
|
||||
|
||||
var explicitFullName =
|
||||
root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
|
||||
? fn.GetString()
|
||||
: null;
|
||||
var fullName = explicitFullName ?? tagConfig;
|
||||
|
||||
var (isHistorized, historianTagname) = ParseHistorize(root);
|
||||
var (isArray, arrayLength) = ParseArray(root);
|
||||
|
||||
return new TagConfigIntent(
|
||||
fullName, explicitFullName, ParseAlarm(root),
|
||||
isHistorized, historianTagname, isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static TagAlarmIntent? ParseAlarm(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
|
||||
// durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new TagAlarmIntent(type, sev, historize);
|
||||
}
|
||||
|
||||
private static (bool IsHistorized, string? HistorianTagname) ParseHistorize(JsonElement root)
|
||||
{
|
||||
var isHistorized = root.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (root.TryGetProperty("historianTagname", out var nEl) && nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; // not trimmed
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
|
||||
private static (bool IsArray, uint? ArrayLength) ParseArray(JsonElement root)
|
||||
{
|
||||
var isArray = root.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& root.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The optional native-alarm intent parsed from a tag's <c>TagConfig.alarm</c> object.</summary>
|
||||
/// <param name="AlarmType">OPC UA Part 9 subtype string; default <c>"AlarmCondition"</c>.</param>
|
||||
/// <param name="Severity">1..1000 severity; default <c>500</c>.</param>
|
||||
/// <param name="HistorizeToAveva">Per-condition durable-write opt-out (<c>bool?</c>; absent ⇒ <see langword="null"/> ⇒ historize).</param>
|
||||
public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
@@ -47,29 +48,16 @@ public static class DraftValidator
|
||||
if (t.EquipmentId is null) continue;
|
||||
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
|
||||
continue;
|
||||
if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig)))
|
||||
// The Galaxy rule wants the EXPLICIT reference, not the raw-blob fallback — so it reads
|
||||
// TagConfigIntent.ExplicitFullName (the "FullName" property only when present-and-string,
|
||||
// else null), preserving the historical null-on-absent semantics by construction (R2-11).
|
||||
if (string.IsNullOrWhiteSpace(TagConfigIntent.Parse(t.TagConfig).ExplicitFullName))
|
||||
errors.Add(new("GalaxyTagMissingReference",
|
||||
$"Galaxy tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
|
||||
t.TagId));
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal reader for the top-level "FullName" string in a tag's schemaless TagConfig JSON
|
||||
// (mirrors AddressSpaceComposer.ExtractTagFullName — a small local copy, consistent with this codebase
|
||||
// where the composer keeps its own).
|
||||
private static string? ExtractTagConfigFullName(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fn)
|
||||
&& fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { return null; }
|
||||
}
|
||||
|
||||
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
||||
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>The outcome of reading an enum-valued field from a TagConfig JSON object.</summary>
|
||||
public enum JsonEnumRead
|
||||
{
|
||||
/// <summary>The property is absent, or present but not a JSON string (nothing to validate).</summary>
|
||||
Absent,
|
||||
|
||||
/// <summary>The property is a JSON string that parses (case-insensitively) to the enum.</summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>The property is a JSON string that does NOT parse to the enum (a typo'd value).</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
|
||||
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
|
||||
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
|
||||
/// namespace — platform intent keys (<c>FullName</c>, <c>alarm</c>, <c>isHistorized</c>, …) coexist
|
||||
/// with per-driver address keys (<c>region</c>, <c>dataType</c>, …) and forward-compat keys the typed
|
||||
/// editors preserve. There is no single canonical schema, so these readers read the known fields and
|
||||
/// ignore the rest; they NEVER warn on unknown keys.</para>
|
||||
/// </summary>
|
||||
public static class TagConfigJson
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads an enum-valued string field, distinguishing absent / valid / present-but-invalid so callers
|
||||
/// can be lenient (fall back) while still <em>reporting</em> the invalid case. A property that is
|
||||
/// absent or present-but-non-string yields <see cref="JsonEnumRead.Absent"/> (there is no enum
|
||||
/// string to validate); a present JSON string that parses case-insensitively yields
|
||||
/// <see cref="JsonEnumRead.Valid"/>; a present JSON string that does not parse yields
|
||||
/// <see cref="JsonEnumRead.Invalid"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <param name="name">The property name to read.</param>
|
||||
/// <param name="value">The parsed enum on <see cref="JsonEnumRead.Valid"/>; otherwise <c>default</c>.</param>
|
||||
/// <returns>The read outcome.</returns>
|
||||
public static JsonEnumRead TryReadEnum<TEnum>(JsonElement o, string name, out TEnum value)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
value = default;
|
||||
return JsonEnumRead.Absent;
|
||||
}
|
||||
if (Enum.TryParse(e.GetString(), ignoreCase: true, out value))
|
||||
return JsonEnumRead.Valid;
|
||||
value = default;
|
||||
return JsonEnumRead.Invalid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lenient enum read with today's exact semantics: absent OR invalid ⇒ <paramref name="fallback"/>;
|
||||
/// only a present, parseable string yields the parsed value. Bit-for-bit equivalent to the six
|
||||
/// retired private <c>ReadEnum</c> copies.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <param name="name">The property name to read.</param>
|
||||
/// <param name="fallback">The value returned when the field is absent or invalid.</param>
|
||||
/// <returns>The parsed enum, or <paramref name="fallback"/>.</returns>
|
||||
public static TEnum ReadEnumOrDefault<TEnum>(JsonElement o, string name, TEnum fallback)
|
||||
where TEnum : struct, Enum
|
||||
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional <c>"writable"</c> flag with AbCip semantics (the model): an explicit JSON
|
||||
/// <c>false</c> ⇒ <see langword="false"/>; anything else (absent, <c>true</c>, or a non-bool token)
|
||||
/// ⇒ <paramref name="defaultValue"/>.
|
||||
/// </summary>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <param name="defaultValue">The value returned unless the field is an explicit JSON <c>false</c>.</param>
|
||||
/// <returns><see langword="false"/> only for an explicit <c>false</c>; otherwise <paramref name="defaultValue"/>.</returns>
|
||||
public static bool ReadWritable(JsonElement o, bool defaultValue = true)
|
||||
=> o.TryGetProperty("writable", out var e) && e.ValueKind == JsonValueKind.False
|
||||
? false
|
||||
: defaultValue;
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable warning for a present-but-invalid enum field (naming the field, the offending
|
||||
/// value, and the valid enum members), or <see langword="null"/> when the field is absent or valid.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type expected.</typeparam>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <param name="name">The property name to describe.</param>
|
||||
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||
public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid) return null;
|
||||
var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null;
|
||||
var valid = string.Join(", ", Enum.GetNames<TEnum>());
|
||||
return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -190,22 +190,8 @@ public static class EquipmentNodeWalker
|
||||
/// </remarks>
|
||||
/// <param name="tagConfig">The tag configuration JSON or string.</param>
|
||||
/// <returns>The extracted <c>FullName</c> value, or the raw <paramref name="tagConfig"/> if it can't be extracted.</returns>
|
||||
internal static string ExtractFullName(string tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through */ }
|
||||
return tagConfig;
|
||||
}
|
||||
internal static string ExtractFullName(string tagConfig) =>
|
||||
TagConfigIntent.Parse(tagConfig).FullName;
|
||||
|
||||
/// <summary>
|
||||
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<!-- R2-11: EquipmentNodeWalker.ExtractFullName delegates to the shared TagConfigIntent.Parse. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user