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:
@@ -689,7 +689,7 @@ public sealed class AddressSpaceApplier
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -66,11 +65,11 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
|
||||
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
|
||||
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
|
||||
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
|
||||
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> — <c>null</c> when there is no device, no
|
||||
/// <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later task graft a
|
||||
/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <c>null</c> when there
|
||||
/// is no device, no <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later
|
||||
/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
||||
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
|
||||
/// the artifact-decode sides (single source of truth: <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>);
|
||||
/// the artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
|
||||
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
|
||||
/// (trim + lower-case) so the two compare equal.</para>
|
||||
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
|
||||
@@ -350,8 +349,8 @@ public static class AddressSpaceComposer
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
|
||||
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
|
||||
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
|
||||
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
|
||||
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
|
||||
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
|
||||
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
|
||||
@@ -360,7 +359,7 @@ public static class AddressSpaceComposer
|
||||
foreach (var d in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
|
||||
deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
|
||||
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
|
||||
}
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
@@ -415,8 +414,11 @@ public static class AddressSpaceComposer
|
||||
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Select(t =>
|
||||
{
|
||||
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
|
||||
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
|
||||
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
|
||||
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
|
||||
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
|
||||
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
return new EquipmentTagPlan(
|
||||
TagId: t.TagId,
|
||||
EquipmentId: t.EquipmentId!,
|
||||
@@ -424,13 +426,13 @@ public static class AddressSpaceComposer
|
||||
FolderPath: t.FolderPath ?? string.Empty,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
FullName: ExtractTagFullName(t.TagConfig),
|
||||
FullName: intent.FullName,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: ExtractTagAlarm(t.TagConfig),
|
||||
IsHistorized: isHistorized,
|
||||
HistorianTagname: historianTagname,
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength);
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -523,165 +525,4 @@ public static class AddressSpaceComposer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
|
||||
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
|
||||
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
|
||||
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
|
||||
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
|
||||
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
|
||||
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
|
||||
private static string ExtractTagFullName(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 to raw blob */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a <see cref="Device"/>'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>) — the same value a
|
||||
/// FOCAS driver emits as its discovered device-host folder segment. Returns <c>null</c> when the
|
||||
/// config is blank, not a JSON object, has no string <c>HostAddress</c>, or the value is
|
||||
/// blank/whitespace. Never throws.
|
||||
/// <para>The returned host is deterministically normalized — trimmed and lower-cased — so the
|
||||
/// live-edit composer side and the artifact-decode side (<c>DeploymentArtifact</c>) agree
|
||||
/// byte-for-byte. This method is the SINGLE SOURCE OF TRUTH for that normalization: the later
|
||||
/// FixedTree-partition task MUST normalize the driver-discovered device-host folder segment the
|
||||
/// same way (call this, or apply the identical trim + lower-case) before comparing the two.</para>
|
||||
/// </summary>
|
||||
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
|
||||
/// <returns>The normalized device host, or <c>null</c> when absent/blank/unparseable.</returns>
|
||||
public static string? TryExtractDeviceHost(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;
|
||||
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
|
||||
return NormalizeDeviceHost(raw);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
|
||||
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
|
||||
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
|
||||
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
|
||||
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
|
||||
/// equal regardless of case/whitespace. Idempotent (a value already normalized 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 NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
|
||||
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.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 EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
|
||||
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
|
||||
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
|
||||
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
|
||||
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
|
||||
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
|
||||
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
|
||||
&& nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
|
||||
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
|
||||
/// <c>arrayLength</c> uint. The length is honoured ONLY when <c>isArray</c> is true AND the prop is a
|
||||
/// JSON number that fits <c>uint</c> (else <c>null</c> ⇒ unbounded 1-D array,
|
||||
/// <c>ArrayDimensions=[0]</c> at materialisation). Mirrors
|
||||
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
|
||||
/// tolerance. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed array flag and optional array length.</returns>
|
||||
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user