feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace. Applier (both realms, explicit realm at every sink call site): - MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath), all in AddressSpaceRealm.Raw; historian tagname = override else RawPath. - MaterialiseUnsReferences: each UNS reference Variable under its equipment folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/ historian tagname from the backing raw tag (both NodeIds -> one tagname). - FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS refs in place (raw-container removal falls back to rebuild). DriverHostActor (dual-NodeId, single-source fan-out): - _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp. Write inverse map keyed by the bare id; the ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a write to either NodeId resolves the same driver ref (-> RawPath write). - Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate + AlarmStateUpdate carry the realm through to the sink. Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now). DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity with the composer (reconstruct entities -> AddressSpaceComposer.Compose). Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink / DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now explicit (realm reordered before the trailing optionals on EnsureVariable + MaterialiseAlarmCondition). Node-manager convenience methods keep their defaults (they are not the interface impl; Sdk delegates explicitly). Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) + DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3 raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
|
||||
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
|
||||
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
|
||||
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
|
||||
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
|
||||
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
|
||||
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
|
||||
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
|
||||
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
|
||||
/// </summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
|
||||
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
|
||||
BuildRawAndUnsSubtrees(JsonElement root)
|
||||
{
|
||||
var rawFolders = new List<RawFolder>();
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
{
|
||||
var id = ReadString(el, "RawFolderId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
rawFolders.Add(new RawFolder
|
||||
{
|
||||
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
|
||||
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
var driverInstances = new List<DriverInstance>();
|
||||
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||
{
|
||||
var id = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
driverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
|
||||
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
|
||||
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
var devices = new List<Device>();
|
||||
foreach (var el in EnumerateArray(root, "Devices"))
|
||||
{
|
||||
var id = ReadString(el, "DeviceId");
|
||||
var di = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||
devices.Add(new Device
|
||||
{
|
||||
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
|
||||
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
|
||||
});
|
||||
}
|
||||
|
||||
var tagGroups = new List<TagGroup>();
|
||||
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||
{
|
||||
var id = ReadString(el, "TagGroupId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
tagGroups.Add(new TagGroup
|
||||
{
|
||||
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
|
||||
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
|
||||
});
|
||||
}
|
||||
|
||||
var tags = new List<Tag>();
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var id = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var name = ReadString(el, "Name");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
|
||||
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
|
||||
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() ?? "{}" : "{}";
|
||||
tags.Add(new Tag
|
||||
{
|
||||
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
|
||||
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
|
||||
AccessLevel = accessLevel, TagConfig = tagConfig,
|
||||
});
|
||||
}
|
||||
|
||||
var unsTagReferences = new List<UnsTagReference>();
|
||||
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||
{
|
||||
var id = ReadString(el, "UnsTagReferenceId");
|
||||
var equipmentId = ReadString(el, "EquipmentId");
|
||||
var tagId = ReadString(el, "TagId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
|
||||
unsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
|
||||
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
|
||||
});
|
||||
}
|
||||
|
||||
var unsAreas = new List<UnsArea>();
|
||||
foreach (var el in EnumerateArray(root, "UnsAreas"))
|
||||
{
|
||||
var id = ReadString(el, "UnsAreaId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
|
||||
}
|
||||
|
||||
var unsLines = new List<UnsLine>();
|
||||
foreach (var el in EnumerateArray(root, "UnsLines"))
|
||||
{
|
||||
var id = ReadString(el, "UnsLineId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
|
||||
}
|
||||
|
||||
var equipment = new List<Equipment>();
|
||||
foreach (var el in EnumerateArray(root, "Equipment"))
|
||||
{
|
||||
var id = ReadString(el, "EquipmentId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
|
||||
Name = ReadString(el, "Name") ?? id!,
|
||||
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
|
||||
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
|
||||
MachineCode = ReadString(el, "MachineCode") ?? id!,
|
||||
});
|
||||
}
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: unsTagReferences, tags: tags,
|
||||
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
|
||||
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
|
||||
|
||||
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
|
||||
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
|
||||
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
|
||||
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
|
||||
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
|
||||
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
||||
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
|
||||
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
|
||||
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
|
||||
// raw VALUE + UNS reference node is correctly cluster-scoped.
|
||||
RawContainers = full.RawContainers,
|
||||
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
|
||||
/// </param>
|
||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||
public static DiscoveredInjectionPlan Map(
|
||||
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
{
|
||||
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
||||
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
||||
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
||||
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
||||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||||
|
||||
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
||||
@@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper
|
||||
for (var i = 0; i < segs.Count; i++)
|
||||
{
|
||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
||||
var nodeId = Combine(rootNodeId, folderPath);
|
||||
if (folders.ContainsKey(nodeId)) continue;
|
||||
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
|
||||
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
||||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||||
}
|
||||
|
||||
var varFolderPath = string.Join('/', segs);
|
||||
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
|
||||
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
|
||||
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
|
||||
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
|
||||
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||
? Combine(rootNodeId, n.BrowseName)
|
||||
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||
// A folder-less variable parents directly at the root device node.
|
||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||
? equipmentId
|
||||
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
|
||||
? rootNodeId
|
||||
: Combine(rootNodeId, varFolderPath);
|
||||
variables.Add(new DiscoveredVariable(
|
||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||
routing[n.FullReference] = varNodeId;
|
||||
|
||||
@@ -111,34 +111,43 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// spawn so a restart's respawn never collides with the still-terminating old child.
|
||||
private long _childSpawnGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment
|
||||
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and
|
||||
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands
|
||||
/// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back
|
||||
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
||||
/// rebuild dedups by NodeId.
|
||||
/// </summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
/// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
|
||||
/// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
|
||||
/// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
|
||||
/// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
|
||||
private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
||||
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/>
|
||||
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an
|
||||
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning
|
||||
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver
|
||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
||||
/// map fans out 1:N because one ref can back several variables).
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
|
||||
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
|
||||
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
|
||||
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
|
||||
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
|
||||
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
|
||||
/// </summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>materialised value NodeId (BARE <c>s=</c> id) →
|
||||
/// (DriverInstanceId, RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS
|
||||
/// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by
|
||||
/// <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c> ∪
|
||||
/// <c>UnsReferenceVariables</c>, and resolved by <see cref="HandleRouteNodeWrite"/> so an inbound
|
||||
/// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref
|
||||
/// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id
|
||||
/// (<c>node.NodeId.ToString()</c>); <see cref="HandleRouteNodeWrite"/> normalises it to the bare id
|
||||
/// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it —
|
||||
/// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
|
||||
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
|
||||
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
|
||||
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
|
||||
/// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
|
||||
/// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
|
||||
/// value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
||||
@@ -620,13 +629,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// equipment variables (identical machines sharing a register), hence the fan-out.
|
||||
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
|
||||
{
|
||||
foreach (var nodeId in nodeIds)
|
||||
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
|
||||
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
|
||||
foreach (var n in nodeIds)
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
|
||||
nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
|
||||
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped",
|
||||
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
|
||||
_localNode, msg.DriverInstanceId, msg.FullReference);
|
||||
}
|
||||
}
|
||||
@@ -918,8 +929,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var key = (driverId, driverRef);
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
|
||||
// through the Raw realm.
|
||||
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
}
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
|
||||
@@ -1005,11 +1018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||
}
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
var nodeId = n.NodeId;
|
||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc));
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
@@ -1087,7 +1101,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target))
|
||||
// v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string
|
||||
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>"). The routing map is keyed by the BARE s= identifier
|
||||
// (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a
|
||||
// referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing
|
||||
// RawPath IS the raw node's, so there is no cross-realm ambiguity).
|
||||
var bareNodeId = BareNodeId(msg.NodeId);
|
||||
if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target))
|
||||
{
|
||||
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}"));
|
||||
return;
|
||||
@@ -1115,6 +1135,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
.PipeTo(replyTo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalise an OPC UA NodeId string to its BARE <c>s=</c> identifier. The node manager's inbound-write
|
||||
/// hook passes the full ns-qualified form (<c>NodeId.ToString()</c> == <c>"ns=<N>;s=<id>"</c>);
|
||||
/// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes
|
||||
/// <c>"ns=N;s="</c> for a string identifier in a non-zero namespace, so the first <c>";s="</c> is the
|
||||
/// delimiter (an id may itself contain <c>";s="</c> later — <c>IndexOf</c> takes the FIRST, which is the
|
||||
/// namespace delimiter). A bare id (no prefix) passes through unchanged.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The (possibly ns-qualified) NodeId string.</param>
|
||||
/// <returns>The bare <c>s=</c> identifier.</returns>
|
||||
internal static string BareNodeId(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId)) return nodeId;
|
||||
var i = nodeId.IndexOf(";s=", StringComparison.Ordinal);
|
||||
if (i >= 0) return nodeId[(i + 3)..];
|
||||
if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..];
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
|
||||
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
|
||||
@@ -1456,89 +1495,85 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they
|
||||
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native
|
||||
// alarm event stream, routed by ForwardNativeAlarm).
|
||||
var refsByDriver = composition.EquipmentTags
|
||||
// v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
|
||||
// per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
|
||||
// raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
|
||||
// excluded from the value set.
|
||||
var refsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's
|
||||
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one
|
||||
// alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the
|
||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by
|
||||
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.EquipmentTags
|
||||
// Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
|
||||
// An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
|
||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
|
||||
// in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is not null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors
|
||||
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to
|
||||
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux
|
||||
// can land driver values on the right node. Clear-and-repopulate every apply so renames
|
||||
// (Name/FolderPath/EquipmentId changes) and removals are reflected.
|
||||
// Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
|
||||
// every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
|
||||
var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
|
||||
foreach (var v in composition.UnsReferenceVariables)
|
||||
{
|
||||
if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list))
|
||||
unsRefsByRawPath[v.BackingRawPath] = list = new List<UnsReferenceVariable>();
|
||||
list.Add(v);
|
||||
}
|
||||
|
||||
// Rebuild the driver live-value + write routing maps from the RawTags ∪ UnsReferenceVariables pass.
|
||||
// Clear-and-repopulate every apply so renames/removals/re-points are reflected.
|
||||
_nodeIdByDriverRef.Clear();
|
||||
// Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an
|
||||
// operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref
|
||||
// FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are
|
||||
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
|
||||
// attribute), so last-writer-wins on the rare duplicate is harmless.
|
||||
_driverRefByNodeId.Clear();
|
||||
// Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
|
||||
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
|
||||
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
|
||||
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across
|
||||
// redeploys (renames/removals/address-space rebuilds).
|
||||
_alarmNodeIdByDriverRef.Clear();
|
||||
// Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId,
|
||||
// FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver
|
||||
// acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME
|
||||
// alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to
|
||||
// exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the
|
||||
// rare duplicate is harmless.
|
||||
_driverRefByAlarmNodeId.Clear();
|
||||
// Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in
|
||||
// the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it.
|
||||
_alarmMetaByNodeId.Clear();
|
||||
_nativeAlarmProjector.Clear();
|
||||
foreach (var t in composition.EquipmentTags)
|
||||
foreach (var t in composition.RawTags)
|
||||
{
|
||||
var key = (t.DriverInstanceId, t.FullName);
|
||||
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
|
||||
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and
|
||||
// keep them OUT of the value maps + value-subscription set (so they don't get both a value
|
||||
// variable AND a condition).
|
||||
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
|
||||
// equipment notifier NodeIds; WP3 wires the single raw condition.
|
||||
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
|
||||
aset.Add(nodeId);
|
||||
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition
|
||||
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA
|
||||
// acknowledge of this condition reaches the right driver child.
|
||||
_driverRefByAlarmNodeId[nodeId] = key;
|
||||
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
|
||||
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
|
||||
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
|
||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
|
||||
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
|
||||
_driverRefByAlarmNodeId[t.NodeId] = key;
|
||||
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
|
||||
// equipment paths; WP3 keys the display off the RawPath.
|
||||
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm)
|
||||
// against the SAME driver ref — the single value source fans to all of them.
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
_driverRefByNodeId[t.NodeId] = key;
|
||||
|
||||
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
|
||||
{
|
||||
foreach (var v in refs)
|
||||
{
|
||||
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
|
||||
_driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
|
||||
|
||||
@@ -39,16 +39,25 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
|
||||
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
|
||||
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
||||
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
|
||||
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
|
||||
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
|
||||
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
|
||||
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
|
||||
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
||||
/// <c>Core.ScriptedAlarms</c>.</summary>
|
||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
|
||||
/// v3 native raw conditions).</param>
|
||||
/// <param name="State">The full condition state to project onto the node.</param>
|
||||
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
|
||||
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
||||
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
@@ -263,7 +272,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
@@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
@@ -340,6 +349,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
||||
var failedNodes = outcome.FailedNodes;
|
||||
failedNodes += _applier.MaterialiseHierarchy(composition);
|
||||
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
|
||||
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
|
||||
// edge finds its raw target. The driver binds live values to these raw NodeIds.
|
||||
failedNodes += _applier.MaterialiseRawSubtree(composition);
|
||||
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
|
||||
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
|
||||
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
|
||||
failedNodes += _applier.MaterialiseUnsReferences(composition);
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
|
||||
@@ -223,9 +223,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared
|
||||
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also
|
||||
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
|
||||
/// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
|
||||
/// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
|
||||
/// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
|
||||
/// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
|
||||
/// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
|
||||
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
|
||||
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name);
|
||||
string.IsNullOrWhiteSpace(p.FolderPath)
|
||||
? V3NodeIds.Uns(p.EquipmentId, p.Name)
|
||||
: V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user