Merge B1-WP4a: server pipeline -> v3 schema + dark address space + DeviceConfig merge

This commit is contained in:
Joseph Doherty
2026-07-15 20:46:28 -04:00
12 changed files with 749 additions and 307 deletions
@@ -0,0 +1,99 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// v3 endpoint→<c>DeviceConfig</c> merge authority. Folds a driver's <c>DriverConfig</c> JSON
/// (protocol/channel-level settings) together with its per-<c>Device</c> <c>DeviceConfig</c> JSON
/// (host/endpoint + per-device settings — the Kepware channel/device split) plus the driver's authored
/// raw tags into the SINGLE config blob every driver factory binds from. Shared by the deploy-decode
/// seam (<c>DeploymentArtifact</c>, which builds each <c>DriverInstanceSpec.DriverConfig</c>) and — in
/// Batch 2 — the browse modal (which needs the same merged shape as the browser's <c>configJson</c>),
/// so the merge lives in exactly one place.
/// <para><b>Contract.</b> Output = the parsed <c>DriverConfig</c> object with, in order:
/// <list type="number">
/// <item>When there is EXACTLY ONE device, its <c>DeviceConfig</c> keys shallow-merged up to the top
/// level (DeviceConfig wins) — so a single-endpoint driver's endpoint (<c>Host</c>/<c>Port</c>,
/// <c>EndpointUrl</c>, gateway address, …) lands where its options bind. Multiple devices do NOT
/// merge up (their endpoints are per-device, read from the array below).</item>
/// <item>A reconstructed <c>Devices</c> array — one object per device = its <c>DeviceConfig</c> keys
/// plus <c>DeviceName</c> = the device's entity <c>Name</c> (the RawPath device segment /
/// <see cref="RawTagEntry.DeviceName"/> routing key). Multi-device drivers read this; single-endpoint
/// drivers ignore the unknown key.</item>
/// <item>A <c>RawTags</c> array = the driver's authored <see cref="RawTagEntry"/> list, so
/// <c>options.RawTags</c> binds.</item>
/// </list>
/// Existing top-level protocol/channel keys survive (single-device DeviceConfig keys override on a name
/// clash — the documented Kepware precedence). Never throws on malformed member JSON: a blank/invalid
/// <c>DriverConfig</c> or <c>DeviceConfig</c> degrades to an empty object for that member.</para>
/// </summary>
public static class DriverDeviceConfigMerger
{
private static readonly JsonSerializerOptions SerializeOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
};
/// <summary>The device rows feeding the merge: the entity <c>Name</c> (routing key) + schemaless <c>DeviceConfig</c> JSON.</summary>
/// <param name="Name">The device's entity <c>Name</c> — the RawPath device segment and multi-device routing key.</param>
/// <param name="DeviceConfig">The device's schemaless <c>DeviceConfig</c> JSON (endpoint + per-device settings).</param>
public readonly record struct DeviceRow(string Name, string? DeviceConfig);
/// <summary>Merge a driver's config + its devices' configs + its authored raw tags into one config blob.</summary>
/// <param name="driverConfigJson">The driver-level <c>DriverConfig</c> JSON (protocol/channel settings).</param>
/// <param name="devices">The driver's device rows, in deterministic order (the sole device merges up).</param>
/// <param name="rawTags">The driver's authored raw tags to inject as the <c>RawTags</c> array.</param>
/// <returns>The single merged config JSON string the driver factory binds from.</returns>
public static string Merge(
string? driverConfigJson,
IReadOnlyList<DeviceRow> devices,
IReadOnlyList<RawTagEntry> rawTags)
{
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(rawTags);
var root = ParseObject(driverConfigJson);
// (1) Exactly one device ⇒ shallow-merge its DeviceConfig up to the top level (DeviceConfig wins),
// so a single-endpoint driver's endpoint lands where its options bind. Multiple devices are
// per-device only (read from the Devices array below), so no merge-up.
if (devices.Count == 1)
{
var only = ParseObject(devices[0].DeviceConfig);
foreach (var (key, value) in only)
root[key] = value?.DeepClone();
}
// (2) Reconstruct the Devices array from ALL device rows: DeviceConfig keys + DeviceName = entity Name.
var devicesArray = new JsonArray();
foreach (var device in devices)
{
var obj = ParseObject(device.DeviceConfig);
obj["DeviceName"] = device.Name; // entity Name is the RawPath device segment + routing key
devicesArray.Add(obj);
}
root["Devices"] = devicesArray;
// (3) Inject the authored raw tags so options.RawTags binds.
root["RawTags"] = JsonSerializer.SerializeToNode(rawTags, SerializeOptions);
return root.ToJsonString(SerializeOptions);
}
/// <summary>Parse JSON into a mutable <see cref="JsonObject"/>; blank / non-object / malformed ⇒ a fresh empty object.</summary>
private static JsonObject ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new JsonObject();
try
{
return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
return new JsonObject();
}
}
}
@@ -0,0 +1,132 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Resolves a raw <c>Tag</c>'s v3 <c>RawPath</c> — the single identity string
/// <c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Tag&gt;</c> — from the raw
/// topology's ancestry maps, and a driver's RawPath prefix (folders + driver name). Pure + input-shape
/// agnostic: callers build the four id→ancestry maps from whatever they have (EF entities on the
/// authoring/validation side, artifact JSON on the deploy-decode side) and both sides then agree
/// byte-for-byte because the segment order + join live ONLY here (over <see cref="RawPaths"/>).
/// <para>Every path is built through <see cref="RawPaths.Combine"/>, so segment charset (no
/// <c>/</c>, no lead/trail whitespace) is enforced in one place. A broken link (missing device / driver
/// lookup, or an invalid segment) yields <see langword="null"/> — never a throw — so a malformed
/// artifact degrades to "this tag has no RawPath" (dropped) rather than faulting a whole deploy decode.
/// Ancestry walks are depth-bounded to survive a corrupt parent cycle.</para>
/// </summary>
public sealed class RawPathResolver
{
private const int MaxAncestryDepth = 256;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _groups;
/// <summary>Initializes a new <see cref="RawPathResolver"/> over the raw topology's ancestry maps.</summary>
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>. <see langword="null"/> parent = cluster root.</param>
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>. <see langword="null"/> folder = cluster root.</param>
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, Name)</c>. <see langword="null"/> parent = directly under the device.</param>
public RawPathResolver(
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
IReadOnlyDictionary<string, (string? ParentId, string Name)> groups)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(drivers);
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(groups);
_folders = folders;
_drivers = drivers;
_devices = devices;
_groups = groups;
}
/// <summary>
/// Build the RawPath prefix a driver contributes to every child tag: its folder ancestry
/// (root → leaf) followed by the driver name. Returns <see langword="null"/> when the driver id is
/// unknown or any segment/link is invalid.
/// </summary>
/// <param name="driverInstanceId">The driver instance id.</param>
/// <returns>The driver RawPath prefix, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildDriverPrefix(string driverInstanceId)
{
if (driverInstanceId is null || !_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
try
{
var path = BuildFolderChain(driver.RawFolderId);
return RawPaths.Combine(path, driver.Name);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>
/// Build a raw tag's full RawPath:
/// <c>&lt;driver-prefix&gt;/&lt;Device&gt;/&lt;TagGroup ancestry root→leaf&gt;/&lt;Tag&gt;</c>.
/// Returns <see langword="null"/> when the device / driver lookup is missing or any segment/link is
/// invalid.
/// </summary>
/// <param name="deviceId">The tag's owning device id.</param>
/// <param name="tagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
/// <param name="tagName">The tag's leaf name.</param>
/// <returns>The tag's RawPath, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildTagPath(string deviceId, string? tagGroupId, string tagName)
{
if (deviceId is null || !_devices.TryGetValue(deviceId, out var device)) return null;
var driverPrefix = TryBuildDriverPrefix(device.DriverInstanceId);
if (driverPrefix is null) return null;
try
{
var path = RawPaths.Combine(driverPrefix, device.Name);
path = AppendGroupChain(path, tagGroupId);
return RawPaths.Combine(path, tagName);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>Build a folder ancestry path (root → leaf) for the given leaf folder id (null = cluster root ⇒ blank).</summary>
private string? BuildFolderChain(string? leafFolderId)
{
if (leafFolderId is null) return null;
var names = new List<string>();
var current = leafFolderId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_folders.TryGetValue(current, out var folder))
throw new ArgumentException($"Unknown RawFolder '{current}'.");
names.Add(folder.Name);
current = folder.ParentId;
}
names.Reverse();
return names.Count == 0 ? null : RawPaths.Build(names);
}
/// <summary>Append a tag-group ancestry path (root → leaf) onto <paramref name="parentPath"/> (null group ⇒ unchanged).</summary>
private string AppendGroupChain(string parentPath, string? leafGroupId)
{
if (leafGroupId is null) return parentPath;
var names = new List<string>();
var current = leafGroupId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_groups.TryGetValue(current, out var group))
throw new ArgumentException($"Unknown TagGroup '{current}'.");
names.Add(group.Name);
current = group.ParentId;
}
names.Reverse();
var path = parentPath;
foreach (var name in names)
path = RawPaths.Combine(path, name);
return path;
}
}
@@ -25,25 +25,36 @@ public sealed class DraftSnapshot
/// </summary>
public string? Site { get; init; }
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
// (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
// effective-leaf uniqueness).
/// <summary>Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<RawFolder> RawFolders { get; init; } = [];
/// <summary>Gets the list of driver instances.</summary>
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
/// <summary>Gets the list of devices.</summary>
public IReadOnlyList<Device> Devices { get; init; } = [];
/// <summary>Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<TagGroup> TagGroups { get; init; } = [];
/// <summary>Gets the list of UNS areas.</summary>
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
/// <summary>Gets the list of UNS lines.</summary>
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
/// <summary>Gets the list of equipment.</summary>
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
/// <summary>Gets the list of tags.</summary>
/// <summary>Gets the list of raw tags.</summary>
public IReadOnlyList<Tag> Tags { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
/// with Tags; the collision rule checks both.</summary>
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
/// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).</summary>
public IReadOnlyList<UnsTagReference> UnsTagReferences { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
/// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).</summary>
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -36,14 +36,20 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
// v3: Namespace entity retired — no namespace list to materialize.
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness).
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
@@ -32,33 +32,110 @@ public static class DraftValidator
ValidateEquipmentUuidImmutability(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors);
return errors;
}
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
{
// v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
/// <summary>v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
/// RawPath segment — no <c>/</c>, no leading/trailing whitespace, non-empty — because <c>/</c> is the
/// RawPath separator and would corrupt the NodeId. Uses <see cref="RawPaths.ValidateSegment"/>, the same
/// charset authority the path builder enforces.</summary>
private static void ValidateRawNameCharset(DraftSnapshot draft, List<ValidationError> errors)
{
void Check(string kind, string id, string name)
{
var items = g.ToList();
if (items.Count <= 1) continue;
var f = items[0];
errors.Add(new("EquipmentSignalNameCollision",
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
"a VirtualTag Name must be unique within an equipment",
f.Eq));
var error = RawPaths.ValidateSegment(name);
if (error is not null)
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
}
foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
}
/// <summary>v3: a historized tag's EFFECTIVE historian tagname (the <c>historianTagname</c> override
/// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
/// A longer name is a deploy error telling the author to set a shorter <c>historianTagname</c> override,
/// never a silent truncation.</summary>
private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List<ValidationError> errors)
{
const int MaxHistorianTagname = 255;
var resolver = BuildRawPathResolver(draft);
foreach (var t in draft.Tags)
{
var intent = TagConfigIntent.Parse(t.TagConfig);
if (!intent.IsHistorized) continue;
// Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
// a separate rule/charset check flags that; nothing to length-check here).
var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (effective is null || effective.Length <= MaxHistorianTagname) continue;
errors.Add(new("HistorianTagnameTooLong",
$"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
"set a shorter 'historianTagname' override in its TagConfig",
t.TagId));
}
}
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
/// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
/// authoring check never saw is caught before it ships.</summary>
private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List<ValidationError> errors)
{
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
// (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
var byLeaf = new Dictionary<(string Eq, string Name), List<string>>();
void Add(string equipmentId, string? effectiveName, string source)
{
if (string.IsNullOrEmpty(effectiveName)) return;
var key = (equipmentId, effectiveName);
if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List<string>();
list.Add(source);
}
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
}
foreach (var v in draft.VirtualTags)
Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
foreach (var a in draft.ScriptedAlarms)
Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
foreach (var ((eq, name), sources) in byLeaf)
{
if (sources.Count <= 1) continue;
errors.Add(new("UnsEffectiveNameCollision",
$"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
eq));
}
}
@@ -214,15 +214,21 @@ public sealed class AdminOperationsActor : ReceiveActor
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
// Running servers are untouched either way — the gate only sees re-deploys.
// v3: a raw Tag's driver is resolved via its Device (Tag no longer carries EquipmentId /
// DriverInstanceId). Inspect EVERY raw tag's TagConfig against its device's driver type.
var driverTypeById = draft.DriverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null))
foreach (var t in draft.Tags)
{
if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue;
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}"));
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
{
@@ -29,16 +29,23 @@ public static class ConfigComposer
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
// v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
// → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
// projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
// PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
// RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
var snapshot = new
{
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
Namespaces = await db.Namespaces.AsNoTracking().OrderBy(x => x.NamespaceId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
@@ -297,41 +297,24 @@ public static class AddressSpaceComposer
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't supply tags.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="devices">The per-device rows used to resolve each equipment's <c>DeviceHost</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Device>? devices = null) =>
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>(), devices: devices);
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
/// <summary>
/// Composes the address space build plan from the configuration entities.
/// Composes the address space build plan from the v3 configuration entities.
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
/// </summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="tags">The tags.</param>
/// <param name="namespaces">The namespaces.</param>
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <param name="devices">The per-device rows (<c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON) used to resolve
/// each equipment's <c>DeviceHost</c> from its bound <c>DeviceId</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -339,28 +322,12 @@ public static class AddressSpaceComposer
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> namespaces,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null,
IReadOnlyList<Device>? devices = null)
IReadOnlyList<Script>? scripts = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
// 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
// side's last-wins). DeviceId is DB-unique so a dupe is defensive-only.
var deviceHostById = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var d in devices ?? Array.Empty<Device>())
{
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
}
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
@@ -373,18 +340,10 @@ public static class AddressSpaceComposer
var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
// bound device's config (null when there's no device or no parseable HostAddress).
.Select(e => new EquipmentNode(
e.EquipmentId,
e.Name,
e.UnsLineId,
DriverInstanceId: e.DriverInstanceId,
DeviceId: e.DeviceId,
DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)))
// DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
// EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
// UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
.ToList();
var plans = driverInstances
@@ -397,55 +356,10 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
// Equipment tags = a Tag bound to an Equipment (non-null EquipmentId) whose driver's namespace
// is Equipment-kind. FullName is the driver-side wire reference pulled from TagConfig — it
// becomes the variable's NodeId + read/write routing key. Galaxy points are ordinary equipment
// tags now (GalaxyMxGateway is a standard Equipment-kind driver), so no driver-type exception.
var equipmentTags = tags
.Where(t => t.EquipmentId is not null)
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& ns.Kind == NamespaceKind.Equipment)
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t =>
{
// 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!,
DriverInstanceId: t.DriverInstanceId,
FolderPath: t.FolderPath ?? string.Empty,
Name: t.Name,
DataType: t.DataType,
FullName: intent.FullName,
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
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();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
// scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused
// across N identical machines resolves to N machine-specific dependency graphs.
var baseByEquip = equipmentTags
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
StringComparer.Ordinal);
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
var equipmentTags = Array.Empty<EquipmentTagPlan>();
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. The {{equip}} token is substituted with the owning equipment's tag
@@ -1,6 +1,7 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -62,17 +63,25 @@ public static class DeploymentArtifact
try
{
using var doc = JsonDocument.Parse(blob.ToArray());
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
var root = doc.RootElement;
if (!root.TryGetProperty("DriverInstances", out var arr)
|| arr.ValueKind != JsonValueKind.Array)
{
return Array.Empty<DriverInstanceSpec>();
}
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
var rawTagsByDriver = BuildRawTagsByDriver(root);
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
foreach (var el in arr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var spec = TryReadSpec(el);
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
if (spec is not null) result.Add(spec);
}
return result;
@@ -83,6 +92,121 @@ public static class DeploymentArtifact
}
}
/// <summary>Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
/// Tags) into each driver's authored <see cref="RawTagEntry"/> list, keyed by DriverInstanceId. Each
/// tag's RawPath is computed via the shared <see cref="RawPathResolver"/> (the single identity authority),
/// so the deploy-decode side agrees byte-for-byte with the authoring/validation side. A tag whose
/// device/driver chain is broken (unknown link) is dropped.</summary>
/// <param name="root">The artifact root element.</param>
/// <returns>DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName).</returns>
private static Dictionary<string, List<RawTagEntry>> BuildRawTagsByDriver(JsonElement root)
{
var byDriver = new Dictionary<string, List<RawTagEntry>>(StringComparer.Ordinal);
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
}
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
}
var deviceToDriver = new Dictionary<string, string>(StringComparer.Ordinal);
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
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;
var name = ReadString(el, "Name") ?? id!;
devices[id!] = (di!, name);
deviceToDriver[id!] = di!;
}
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
}
var resolver = new RawPathResolver(folders, drivers, devices, groups);
foreach (var el in EnumerateArray(root, "Tags"))
{
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
if (!deviceToDriver.TryGetValue(deviceId!, out var driverInstanceId)) continue;
var tagGroupId = ReadNullableString(el, "TagGroupId");
var rawPath = resolver.TryBuildTagPath(deviceId!, tagGroupId, name!);
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() ?? "{}" : "{}";
// WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent).
var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl)
&& wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean();
var deviceName = devices.TryGetValue(deviceId!, out var dev) ? dev.Name : string.Empty;
if (!byDriver.TryGetValue(driverInstanceId, out var list))
byDriver[driverInstanceId] = list = new List<RawTagEntry>();
list.Add(new RawTagEntry(rawPath, tagConfig, writeIdempotent, deviceName));
}
// Deterministic ordering per driver (ordinal RawPath) so the injected config is byte-stable.
foreach (var list in byDriver.Values)
list.Sort((a, b) => string.CompareOrdinal(a.RawPath, b.RawPath));
return byDriver;
}
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
/// <param name="root">The artifact root element.</param>
/// <returns>DriverInstanceId → ordered device rows.</returns>
private static Dictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> BuildDeviceRowsByDriver(JsonElement root)
{
var byDriver = new Dictionary<string, List<(string DeviceId, DriverDeviceConfigMerger.DeviceRow Row)>>(StringComparer.Ordinal);
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;
var name = ReadString(el, "Name") ?? id!;
var deviceConfig = ReadString(el, "DeviceConfig");
if (!byDriver.TryGetValue(di!, out var list))
byDriver[di!] = list = new List<(string, DriverDeviceConfigMerger.DeviceRow)>();
list.Add((id!, new DriverDeviceConfigMerger.DeviceRow(name, deviceConfig)));
}
return byDriver.ToDictionary(
kv => kv.Key,
kv => kv.Value.OrderBy(x => x.DeviceId, StringComparer.Ordinal).Select(x => x.Row).ToList(),
StringComparer.Ordinal);
}
/// <summary>Enumerate an artifact array property's object elements (empty when absent/non-array).</summary>
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, string property)
{
if (!root.TryGetProperty(property, out var arr) || arr.ValueKind != JsonValueKind.Array)
yield break;
foreach (var el in arr.EnumerateArray())
if (el.ValueKind == JsonValueKind.Object)
yield return el;
}
/// <summary>Reads a string property, coalescing a JSON <c>null</c> (or wrong type) to <see langword="null"/>.</summary>
private static string? ReadNullableString(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
/// <summary>
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
@@ -155,7 +279,10 @@ public static class DeploymentArtifact
private static string? ReadString(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
private static DriverInstanceSpec? TryReadSpec(
JsonElement el,
IReadOnlyDictionary<string, List<RawTagEntry>> rawTagsByDriver,
IReadOnlyDictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> deviceRowsByDriver)
{
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
@@ -166,7 +293,7 @@ public static class DeploymentArtifact
var enabled = !el.TryGetProperty("Enabled", out var enEl)
|| enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
|| enEl.GetBoolean();
var config = ReadString(el, "DriverConfig");
var driverConfig = ReadString(el, "DriverConfig");
var clusterId = ReadString(el, "ClusterId");
// Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable —
// absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn.
@@ -174,13 +301,20 @@ public static class DeploymentArtifact
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
// v3 endpoint→DeviceConfig + RawTags injection: fold DriverConfig + per-device DeviceConfig + the
// driver's authored raw tags into the single config the factory binds from (single-endpoint drivers
// get their sole DeviceConfig merged up; multi-device drivers get a reconstructed Devices[] array).
var devices = deviceRowsByDriver.TryGetValue(id!, out var dr) ? dr : (IReadOnlyList<DriverDeviceConfigMerger.DeviceRow>)Array.Empty<DriverDeviceConfigMerger.DeviceRow>();
var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList<RawTagEntry>)Array.Empty<RawTagEntry>();
var mergedConfig = DriverDeviceConfigMerger.Merge(driverConfig, devices, rawTags);
return new DriverInstanceSpec(
DriverInstanceRowId: rowId,
DriverInstanceId: id!,
Name: name ?? id!,
DriverType: type!,
Enabled: enabled,
DriverConfig: config ?? "{}",
DriverConfig: mergedConfig,
ClusterId: clusterId,
ResilienceConfig: resilienceConfig);
}
@@ -208,13 +342,14 @@ public static class DeploymentArtifact
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
// DeviceId → connection host, resolved from the artifact's Devices array via the SAME shared
// helper the composer uses, so each EquipmentNode.DeviceHost is byte-parity-equal across seams.
var deviceHostById = BuildDeviceHostMap(root);
var equipment = ReadArray(root, "Equipment", el => ReadEquipmentNode(el, deviceHostById));
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var equipmentTags = BuildEquipmentTagPlans(root);
// v3 DARK address space (Batch 1): NO equipment-tag variable plans materialize — the raw + UNS
// variable nodes are lit up in Batch 4 (the dual namespace + UNS fan-out). EquipmentTags is
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
var equipmentTags = Array.Empty<EquipmentTagPlan>();
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
@@ -326,21 +461,18 @@ public static class DeploymentArtifact
lineToArea[lineId!] = areaId!;
}
}
// Equipment carries no ClusterId driver-bound equipment is attributed by its driver's
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
// v3: Equipment carries no ClusterId AND no driver binding — it is attributed solely by its
// UNS line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
{
foreach (var el in eq.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var inByDriver = di is not null && driverIds.Contains(di);
var inByLine = di is null && lineId is not null
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
if (inByDriver || inByLine) equipmentIds.Add(id!);
if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine))
equipmentIds.Add(id!);
}
}
}
@@ -376,119 +508,6 @@ public static class DeploymentArtifact
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
/// <summary>
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
/// <c>AddressSpaceComposer.Compose</c>'s equipment filter — so the compose-side + artifact-decode
/// plans agree on the same set of tags. FullName is read from each tag's TagConfig blob
/// (top-level "FullName" field).
/// </summary>
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
{
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
// namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0);
// tolerate the string form too.
var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal);
foreach (var el in nsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
var isEquipment = kindEl.ValueKind switch
{
JsonValueKind.Number => kindEl.GetInt32() == 0, // NamespaceKind.Equipment = 0
JsonValueKind.String => string.Equals(kindEl.GetString(), "Equipment", StringComparison.Ordinal),
_ => false,
};
if (isEquipment) equipmentNamespaces.Add(id!);
}
// driverInstanceId → namespaceId.
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in diArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
driverToNamespace[id!] = ns!;
}
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
foreach (var el in tagsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
// Equipment tags REQUIRE a non-null EquipmentId (the inverse of the Galaxy filter).
if (!el.TryGetProperty("EquipmentId", out var eqEl) || eqEl.ValueKind == JsonValueKind.Null) continue;
var equipmentId = eqEl.GetString();
if (string.IsNullOrWhiteSpace(equipmentId)) continue;
var tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
? fpEl.GetString() : null;
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() : null;
// AccessLevel → Writable. ConfigComposer serialises the TagAccessLevel enum WITHOUT a
// string converter, so it lands as a number (Read = 0, ReadWrite = 1); tolerate the string
// form ("ReadWrite") too — same defensive both-forms parse as the Kind gate above. MUST match
// AddressSpaceComposer's `AccessLevel == TagAccessLevel.ReadWrite` exactly (byte-parity). A missing
// field defaults to non-writable (read-only).
var writable = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind switch
{
JsonValueKind.Number => alEl.GetInt32() == (int)TagAccessLevel.ReadWrite,
JsonValueKind.String => string.Equals(alEl.GetString(), nameof(TagAccessLevel.ReadWrite), StringComparison.Ordinal),
_ => false,
};
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
// Equipment-kind namespace only — byte-parity with the composer's pure
// `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
if (!equipmentNamespaces.Contains(nsId)) continue;
// Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
// (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
// consumes, so the artifact-decode plan stays byte-equal with the composer's.
var intent = TagConfigIntent.Parse(tagConfig);
result.Add(new EquipmentTagPlan(
TagId: tagId!,
EquipmentId: equipmentId!,
DriverInstanceId: di!,
FolderPath: folder ?? string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
FullName: intent.FullName,
Writable: writable,
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));
}
result.Sort((a, b) =>
{
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
if (byEquipment != 0) return byEquipment;
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
if (byFolder != 0) return byFolder;
return string.CompareOrdinal(a.Name, b.Name);
});
return result;
}
/// <summary>
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
@@ -712,29 +731,7 @@ public static class DeploymentArtifact
return new UnsLineProjection(id!, areaId!, name ?? id!);
}
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> so the artifact-decode side
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
/// <param name="root">The artifact root element.</param>
/// <returns>The resolved DeviceId → host map (host may be null when a device has no parseable HostAddress).</returns>
private static IReadOnlyDictionary<string, string?> BuildDeviceHostMap(JsonElement root)
{
var map = new Dictionary<string, string?>(StringComparer.Ordinal);
if (!root.TryGetProperty("Devices", out var arr) || arr.ValueKind != JsonValueKind.Array)
return map;
foreach (var el in arr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var deviceId = ReadString(el, "DeviceId");
if (string.IsNullOrWhiteSpace(deviceId)) continue;
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
}
return map;
}
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
{
var id = ReadString(el, "EquipmentId");
// DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine
@@ -743,19 +740,10 @@ public static class DeploymentArtifact
var displayName = ReadString(el, "Name");
var lineId = ReadString(el, "UnsLineId");
if (string.IsNullOrWhiteSpace(id)) return null;
// DriverInstanceId / DeviceId copied straight from the row (null when absent / JSON null);
// DeviceHost resolved from the device-host map by DeviceId — byte-parity with the composer's
// `e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
var driverInstanceId = ReadString(el, "DriverInstanceId");
var deviceId = ReadString(el, "DeviceId");
var deviceHost = deviceId is null ? null : deviceHostById.GetValueOrDefault(deviceId);
return new EquipmentNode(
id!,
displayName ?? id!,
lineId ?? string.Empty,
DriverInstanceId: driverInstanceId,
DeviceId: deviceId,
DeviceHost: deviceHost);
// v3: Equipment no longer binds a driver/device — it references raw tags via UnsTagReference.
// The EquipmentNode's DriverInstanceId/DeviceId/DeviceHost hooks (used by the multi-device FixedTree
// partition path) are therefore always null now; Batch 4 owns the UNS↔Raw fan-out that lights them up.
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
}
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
@@ -112,7 +112,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// 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 FullName), HashSet<string>> _nodeIdByDriverRef = new();
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
@@ -123,14 +123,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// 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).
/// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
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 FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
@@ -142,7 +142,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// single driver alarm), so this is a flat 1:1 map (the forward map fans out 1:N because one ref can
/// back several conditions on identical machines).
/// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeId =
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByAlarmNodeId =
new(StringComparer.Ordinal);
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building
@@ -1094,7 +1094,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// the continuation: it runs off the actor thread, where raw Sender is unsafe to read.
var replyTo = Sender;
entry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(
new DriverInstanceActor.WriteAttribute(target.FullName, msg.Value!), TimeSpan.FromSeconds(8))
new DriverInstanceActor.WriteAttribute(target.RawPath, msg.Value!), TimeSpan.FromSeconds(8))
.ContinueWith(
t => t.IsCompletedSuccessfully
? new NodeWriteResult(t.Result.Success, t.Result.Reason)
@@ -1164,7 +1164,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.FullName, msg.Comment, msg.OperatorUser));
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser));
}
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
@@ -0,0 +1,113 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// v3 (B1-WP4) identity — the shared <see cref="RawPathResolver"/> must build a raw tag's RawPath by
/// walking RawFolder → Driver → Device → TagGroup ancestry, and <see cref="DriverDeviceConfigMerger"/>
/// must fold the endpoint + RawTags into the single driver config. This is the byte-parity authority
/// both the deploy artifact and the draft validator compute against.
/// </summary>
public sealed class RawPathResolverTests
{
private static RawPathResolver Nested() => new(
folders: new Dictionary<string, (string?, string)>
{
["f1"] = (null, "Plant"),
["f2"] = ("f1", "Line1"),
},
drivers: new Dictionary<string, (string?, string)>
{
["drv1"] = ("f2", "Modbus1"),
["drvRoot"] = (null, "S7Root"),
},
devices: new Dictionary<string, (string, string)>
{
["dev1"] = ("drv1", "PLC-A"),
["devRoot"] = ("drvRoot", "PLC-B"),
},
groups: new Dictionary<string, (string?, string)>
{
["g1"] = (null, "Motors"),
["g2"] = ("g1", "M1"),
});
[Fact]
public void TryBuildTagPath_walks_folder_driver_device_group_ancestry()
{
Nested().TryBuildTagPath("dev1", "g2", "Speed")
.ShouldBe("Plant/Line1/Modbus1/PLC-A/Motors/M1/Speed");
}
[Fact]
public void TryBuildTagPath_device_root_tag_has_no_group_segments()
{
Nested().TryBuildTagPath("dev1", null, "Status")
.ShouldBe("Plant/Line1/Modbus1/PLC-A/Status");
}
[Fact]
public void TryBuildTagPath_cluster_root_driver_has_no_folder_segments()
{
Nested().TryBuildTagPath("devRoot", null, "Run")
.ShouldBe("S7Root/PLC-B/Run");
}
[Fact]
public void TryBuildTagPath_unknown_device_returns_null()
{
Nested().TryBuildTagPath("ghost", null, "X").ShouldBeNull();
}
[Fact]
public void TryBuildDriverPrefix_is_folders_plus_driver_name()
{
Nested().TryBuildDriverPrefix("drv1").ShouldBe("Plant/Line1/Modbus1");
}
[Fact]
public void Merge_single_device_folds_endpoint_up_and_injects_rawtags()
{
var merged = DriverDeviceConfigMerger.Merge(
driverConfigJson: "{\"UnitId\":3}",
devices: new[] { new DriverDeviceConfigMerger.DeviceRow("PLC-A", "{\"Host\":\"10.0.0.5\",\"Port\":502}") },
rawTags: new[] { new RawTagEntry("Plant/Modbus1/PLC-A/Speed", "{\"address\":\"40001\"}", true, "PLC-A") });
using var doc = JsonDocument.Parse(merged);
var root = doc.RootElement;
root.GetProperty("UnitId").GetInt32().ShouldBe(3);
root.GetProperty("Host").GetString().ShouldBe("10.0.0.5"); // single-device endpoint merged up
root.GetProperty("Port").GetInt32().ShouldBe(502);
var rawTags = root.GetProperty("RawTags").EnumerateArray().ToList();
rawTags.ShouldHaveSingleItem();
rawTags[0].GetProperty("RawPath").GetString().ShouldBe("Plant/Modbus1/PLC-A/Speed");
var devices = root.GetProperty("Devices").EnumerateArray().ToList();
devices.ShouldHaveSingleItem();
devices[0].GetProperty("DeviceName").GetString().ShouldBe("PLC-A");
}
[Fact]
public void Merge_multi_device_reconstructs_devices_array_with_names()
{
var merged = DriverDeviceConfigMerger.Merge(
driverConfigJson: "{\"Series\":\"0i\"}",
devices: new[]
{
new DriverDeviceConfigMerger.DeviceRow("PLC-A", "{\"HostAddress\":\"10.0.0.5:8193\"}"),
new DriverDeviceConfigMerger.DeviceRow("PLC-B", "{\"HostAddress\":\"10.0.0.6:8193\"}"),
},
rawTags: Array.Empty<RawTagEntry>());
using var doc = JsonDocument.Parse(merged);
var devices = doc.RootElement.GetProperty("Devices").EnumerateArray().ToList();
devices.Count.ShouldBe(2);
devices.Select(d => d.GetProperty("DeviceName").GetString()).ShouldBe(new[] { "PLC-A", "PLC-B" });
devices[0].GetProperty("HostAddress").GetString().ShouldBe("10.0.0.5:8193");
// Multiple devices: no top-level endpoint merge-up (per-device only).
doc.RootElement.TryGetProperty("HostAddress", out _).ShouldBeFalse();
}
}
@@ -0,0 +1,89 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 (B1-WP4) — the deploy artifact must compute each raw tag's RawPath by walking
/// RawFolder → Driver → Device → TagGroup ancestry, inject the per-driver RawTags into the driver's
/// merged config, and fold the sole device's DeviceConfig endpoint up to the top level. A strong signal
/// for the docker-dev live gate (a driver that gets the wrong RawPath or a missing endpoint won't bind).
/// </summary>
public sealed class DeploymentArtifactRawPathTests
{
private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);
private static object NestedSingleDeviceSnapshot() => new
{
Clusters = new[] { new { ClusterId = "MAIN" } },
Nodes = Array.Empty<object>(),
RawFolders = new[]
{
new { RawFolderId = "f1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "MAIN" },
new { RawFolderId = "f2", ParentRawFolderId = (string?)"f1", Name = "Line1", ClusterId = "MAIN" },
},
DriverInstances = new[]
{
new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "drv1", Name = "Modbus1", DriverType = "Modbus", Enabled = true, DriverConfig = "{\"UnitId\":3}", ClusterId = "MAIN", RawFolderId = "f2" },
},
Devices = new[]
{
new { DeviceId = "dev1", DriverInstanceId = "drv1", Name = "PLC-A", DeviceConfig = "{\"Host\":\"10.0.0.5\",\"Port\":502}" },
},
TagGroups = new[]
{
new { TagGroupId = "g1", DeviceId = "dev1", ParentTagGroupId = (string?)null, Name = "Motors" },
new { TagGroupId = "g2", DeviceId = "dev1", ParentTagGroupId = (string?)"g1", Name = "M1" },
},
Tags = new[]
{
new { TagId = "t1", DeviceId = "dev1", TagGroupId = (string?)"g2", Name = "Speed", DataType = "Float", TagConfig = "{\"address\":\"40001\"}", WriteIdempotent = true },
new { TagId = "t2", DeviceId = "dev1", TagGroupId = (string?)null, Name = "Status", DataType = "Boolean", TagConfig = "{\"address\":\"1\"}", WriteIdempotent = false },
},
};
/// <summary>The nested folder + nested group tag resolves to
/// <c>Plant/Line1/Modbus1/PLC-A/Motors/M1/Speed</c>; a device-root tag drops the group segments.</summary>
[Fact]
public void ParseDriverInstances_computes_nested_RawPath_and_merges_device_endpoint()
{
var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(NestedSingleDeviceSnapshot()));
var spec = specs.ShouldHaveSingleItem();
spec.DriverInstanceId.ShouldBe("drv1");
using var doc = JsonDocument.Parse(spec.DriverConfig);
var root = doc.RootElement;
// Endpoint from the sole device's DeviceConfig merged up to the top level (single-endpoint driver).
root.GetProperty("Host").GetString().ShouldBe("10.0.0.5");
root.GetProperty("Port").GetInt32().ShouldBe(502);
// Protocol/channel setting from the driver-level DriverConfig survives.
root.GetProperty("UnitId").GetInt32().ShouldBe(3);
// RawTags injected, ordinal-ordered by RawPath, each carrying its computed RawPath + device name.
var rawTags = root.GetProperty("RawTags").EnumerateArray().ToList();
rawTags.Count.ShouldBe(2);
var paths = rawTags.Select(e => e.GetProperty("RawPath").GetString()).ToList();
paths.ShouldContain("Plant/Line1/Modbus1/PLC-A/Motors/M1/Speed");
paths.ShouldContain("Plant/Line1/Modbus1/PLC-A/Status");
var speed = rawTags.Single(e => e.GetProperty("RawPath").GetString() == "Plant/Line1/Modbus1/PLC-A/Motors/M1/Speed");
speed.GetProperty("WriteIdempotent").GetBoolean().ShouldBeTrue();
speed.GetProperty("DeviceName").GetString().ShouldBe("PLC-A");
// Reconstructed Devices array carries the device name (routing key) + endpoint.
var devices = root.GetProperty("Devices").EnumerateArray().ToList();
devices.ShouldHaveSingleItem();
devices[0].GetProperty("DeviceName").GetString().ShouldBe("PLC-A");
}
/// <summary>The DARK address space this batch: no equipment-tag variable plans materialize.</summary>
[Fact]
public void ParseComposition_emits_dark_address_space_no_equipment_tags()
{
var composition = DeploymentArtifact.ParseComposition(BlobOf(NestedSingleDeviceSnapshot()));
composition.EquipmentTags.ShouldBeEmpty();
}
}