feat(opcua): EquipmentTagPlan IsArray/ArrayLength + composer ExtractTagArray + applier wire-in

This commit is contained in:
Joseph Doherty
2026-06-16 21:27:43 -04:00
parent 3172b7bdee
commit 71cc417182
4 changed files with 236 additions and 5 deletions
@@ -211,7 +211,7 @@ public sealed class Phase7Applier
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname);
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname, tag.IsArray, tag.ArrayLength);
}
}
@@ -309,9 +309,9 @@ public sealed class Phase7Applier
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
}
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname); }
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
}
@@ -83,6 +83,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// <c>historianTagname</c> string (Phase C); a null <see cref="HistorianTagname"/> means the historian
/// tagname defaults to <see cref="FullName"/> (resolved later, not here). Both are parsed identically
/// on the artifact-decode side for byte-parity.
/// <see cref="IsArray"/> / <see cref="ArrayLength"/> carry the optional array intent parsed from
/// <c>Tag.TagConfig</c>'s <c>isArray</c> bool + <c>arrayLength</c> uint: when <see cref="IsArray"/>
/// the variable materialises as a 1-D array (<c>ValueRank=OneDimension</c>,
/// <c>ArrayDimensions=[ArrayLength]</c>) rather than a scalar. A null <see cref="ArrayLength"/> means
/// length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity.
/// </summary>
public sealed record EquipmentTagPlan(
string TagId,
@@ -95,7 +100,9 @@ public sealed record EquipmentTagPlan(
bool Writable,
EquipmentTagAlarmInfo? Alarm,
bool IsHistorized = false,
string? HistorianTagname = null);
string? HistorianTagname = null,
bool IsArray = false,
uint? ArrayLength = null);
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
@@ -352,6 +359,7 @@ public static class Phase7Composer
.Select(t =>
{
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
return new EquipmentTagPlan(
TagId: t.TagId,
EquipmentId: t.EquipmentId!,
@@ -363,7 +371,9 @@ public static class Phase7Composer
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: ExtractTagAlarm(t.TagConfig),
IsHistorized: isHistorized,
HistorianTagname: historianTagname);
HistorianTagname: historianTagname,
IsArray: isArray,
ArrayLength: arrayLength);
})
.ToList();
@@ -537,4 +547,34 @@ public static class Phase7Composer
}
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, resolved later). 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>
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); }
}
}