Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s

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:
Joseph Doherty
2026-07-13 11:29:32 -04:00
74 changed files with 2228 additions and 682 deletions
@@ -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); }
}
}