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
@@ -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);