v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space
- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences), drop the retired Namespaces snapshot; RevisionHash follows the new blob. - DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device-> TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged DriverConfig+DeviceConfig config injected into DriverInstanceSpec via DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster scoping attributes equipment by UNS line only. - AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind, no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror). - DriverHostActor: fan-out/write map tuple member FullName -> RawPath. - Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger (single/multi-device endpoint+RawTags merge). - DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment), historized effective-tagname <=255, UNS effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables. - AdminOperationsActor: tag-config inspection resolves driver via Device (no EquipmentId/DriverInstanceId on Tag). - Tests: RawPathResolver/merger unit tests (Commons.Tests, green) + DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
This commit is contained in:
@@ -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><Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag></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><driver-prefix>/<Device>/<TagGroup ancestry root→leaf>/<Tag></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>
|
/// </summary>
|
||||||
public string? Site { get; init; }
|
public string? Site { get; init; }
|
||||||
|
|
||||||
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
|
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
|
||||||
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
|
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
|
||||||
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
|
// (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>
|
/// <summary>Gets the list of driver instances.</summary>
|
||||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||||
/// <summary>Gets the list of devices.</summary>
|
/// <summary>Gets the list of devices.</summary>
|
||||||
public IReadOnlyList<Device> Devices { get; init; } = [];
|
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>
|
/// <summary>Gets the list of UNS areas.</summary>
|
||||||
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||||
/// <summary>Gets the list of UNS lines.</summary>
|
/// <summary>Gets the list of UNS lines.</summary>
|
||||||
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||||
/// <summary>Gets the list of equipment.</summary>
|
/// <summary>Gets the list of equipment.</summary>
|
||||||
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
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; } = [];
|
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
|
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
|
||||||
/// with Tags; the collision rule checks both.</summary>
|
/// 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; } = [];
|
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>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
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.
|
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
|
||||||
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
||||||
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
|
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),
|
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
|
||||||
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
|
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
|
||||||
|
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
|
||||||
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
|
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
|
||||||
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
|
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
|
||||||
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
|
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
|
||||||
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
|
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
|
||||||
|
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||||
|
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||||
ActiveReservations = await db.ExternalIdReservations
|
ActiveReservations = await db.ExternalIdReservations
|
||||||
|
|||||||
@@ -32,33 +32,110 @@ public static class DraftValidator
|
|||||||
ValidateEquipmentUuidImmutability(draft, errors);
|
ValidateEquipmentUuidImmutability(draft, errors);
|
||||||
ValidateReservationPreflight(draft, errors);
|
ValidateReservationPreflight(draft, errors);
|
||||||
ValidateEquipmentIdDerivation(draft, errors);
|
ValidateEquipmentIdDerivation(draft, errors);
|
||||||
ValidateNoEquipmentSignalNameCollision(draft, errors);
|
|
||||||
|
|
||||||
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||||
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
|
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
||||||
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
|
ValidateRawNameCharset(draft, errors);
|
||||||
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
|
ValidateHistorizedTagnameLength(draft, errors);
|
||||||
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
|
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||||
return 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
|
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||||
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||||
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
|
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
|
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||||
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
|
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)
|
||||||
{
|
{
|
||||||
var items = g.ToList();
|
void Check(string kind, string id, string name)
|
||||||
if (items.Count <= 1) continue;
|
{
|
||||||
var f = items[0];
|
var error = RawPaths.ValidateSegment(name);
|
||||||
errors.Add(new("EquipmentSignalNameCollision",
|
if (error is not null)
|
||||||
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
|
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
|
||||||
"a VirtualTag Name must be unique within an equipment",
|
}
|
||||||
f.Eq));
|
|
||||||
|
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
|
// (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.
|
// 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.
|
// 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
|
var driverTypeById = draft.DriverInstances
|
||||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(g => g.Key, g => g.First().DriverType, 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)>();
|
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))
|
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)
|
if (tagConfigWarnings.Count > 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,16 +29,23 @@ public static class ConfigComposer
|
|||||||
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
|
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
|
||||||
OtOpcUaConfigDbContext db, CancellationToken ct = default)
|
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
|
var snapshot = new
|
||||||
{
|
{
|
||||||
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
|
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
|
||||||
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).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),
|
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
|
||||||
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).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),
|
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
|
||||||
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).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),
|
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),
|
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
|
||||||
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
|
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
|
||||||
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).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<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, 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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="unsAreas">The UNS areas.</param>
|
/// <param name="unsAreas">The UNS areas.</param>
|
||||||
/// <param name="unsLines">The UNS lines.</param>
|
/// <param name="unsLines">The UNS lines.</param>
|
||||||
/// <param name="equipment">The equipment.</param>
|
/// <param name="equipment">The equipment.</param>
|
||||||
/// <param name="driverInstances">The driver instances.</param>
|
/// <param name="driverInstances">The driver instances.</param>
|
||||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||||
/// <param name="tags">The tags.</param>
|
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
|
||||||
/// <param name="namespaces">The namespaces.</param>
|
|
||||||
/// <param name="virtualTags">The Equipment-namespace 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="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>
|
/// <returns>The composition result.</returns>
|
||||||
public static AddressSpaceComposition Compose(
|
public static AddressSpaceComposition Compose(
|
||||||
IReadOnlyList<UnsArea> unsAreas,
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
@@ -339,28 +322,12 @@ public static class AddressSpaceComposer
|
|||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||||
IReadOnlyList<Tag> tags,
|
|
||||||
IReadOnlyList<Namespace> namespaces,
|
|
||||||
IReadOnlyList<VirtualTag>? virtualTags = null,
|
IReadOnlyList<VirtualTag>? virtualTags = null,
|
||||||
IReadOnlyList<Script>? scripts = null,
|
IReadOnlyList<Script>? scripts = null)
|
||||||
IReadOnlyList<Device>? devices = null)
|
|
||||||
{
|
{
|
||||||
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
||||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
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
|
var areas = unsAreas
|
||||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||||
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
||||||
@@ -373,18 +340,10 @@ public static class AddressSpaceComposer
|
|||||||
|
|
||||||
var nodes = equipment
|
var nodes = equipment
|
||||||
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
||||||
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
|
// DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
|
||||||
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
|
// EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
|
||||||
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
|
// UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
|
||||||
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
|
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
|
||||||
// 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)))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var plans = driverInstances
|
var plans = driverInstances
|
||||||
@@ -397,55 +356,10 @@ public static class AddressSpaceComposer
|
|||||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
|
||||||
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
|
||||||
|
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||||
// Equipment tags = a Tag bound to an Equipment (non-null EquipmentId) whose driver's namespace
|
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
// 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
|
// expression source. The {{equip}} token is substituted with the owning equipment's tag
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -62,17 +63,25 @@ public static class DeploymentArtifact
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
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)
|
|| arr.ValueKind != JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
return Array.Empty<DriverInstanceSpec>();
|
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());
|
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||||
foreach (var el in arr.EnumerateArray())
|
foreach (var el in arr.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
var spec = TryReadSpec(el);
|
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
|
||||||
if (spec is not null) result.Add(spec);
|
if (spec is not null) result.Add(spec);
|
||||||
}
|
}
|
||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
/// 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
|
/// (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) =>
|
private static string? ReadString(JsonElement el, string property) =>
|
||||||
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
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)
|
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
||||||
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
||||||
@@ -166,7 +293,7 @@ public static class DeploymentArtifact
|
|||||||
var enabled = !el.TryGetProperty("Enabled", out var enEl)
|
var enabled = !el.TryGetProperty("Enabled", out var enEl)
|
||||||
|| enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
|
|| enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
|
||||||
|| enEl.GetBoolean();
|
|| enEl.GetBoolean();
|
||||||
var config = ReadString(el, "DriverConfig");
|
var driverConfig = ReadString(el, "DriverConfig");
|
||||||
var clusterId = ReadString(el, "ClusterId");
|
var clusterId = ReadString(el, "ClusterId");
|
||||||
// Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable —
|
// Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable —
|
||||||
// absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn.
|
// 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;
|
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(
|
return new DriverInstanceSpec(
|
||||||
DriverInstanceRowId: rowId,
|
DriverInstanceRowId: rowId,
|
||||||
DriverInstanceId: id!,
|
DriverInstanceId: id!,
|
||||||
Name: name ?? id!,
|
Name: name ?? id!,
|
||||||
DriverType: type!,
|
DriverType: type!,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
DriverConfig: config ?? "{}",
|
DriverConfig: mergedConfig,
|
||||||
ClusterId: clusterId,
|
ClusterId: clusterId,
|
||||||
ResilienceConfig: resilienceConfig);
|
ResilienceConfig: resilienceConfig);
|
||||||
}
|
}
|
||||||
@@ -208,13 +342,14 @@ public static class DeploymentArtifact
|
|||||||
|
|
||||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||||
// DeviceId → connection host, resolved from the artifact's Devices array via the SAME shared
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||||
// 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 drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
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 equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||||
|
|
||||||
@@ -326,21 +461,18 @@ public static class DeploymentArtifact
|
|||||||
lineToArea[lineId!] = areaId!;
|
lineToArea[lineId!] = areaId!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's
|
// v3: Equipment carries no ClusterId AND no driver binding — it is attributed solely by its
|
||||||
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
|
// UNS line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
|
||||||
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
|
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
foreach (var el in eq.EnumerateArray())
|
foreach (var el in eq.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
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 id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||||
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
|
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
|
||||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
var inByDriver = di is not null && driverIds.Contains(di);
|
if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine))
|
||||||
var inByLine = di is null && lineId is not null
|
equipmentIds.Add(id!);
|
||||||
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
|
|
||||||
if (inByDriver || inByLine) equipmentIds.Add(id!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,119 +508,6 @@ public static class DeploymentArtifact
|
|||||||
Array.Empty<DriverInstancePlan>(),
|
Array.Empty<DriverInstancePlan>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
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>
|
/// <summary>
|
||||||
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
/// 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
|
/// <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!);
|
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
|
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||||
/// (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)
|
|
||||||
{
|
{
|
||||||
var id = ReadString(el, "EquipmentId");
|
var id = ReadString(el, "EquipmentId");
|
||||||
// DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine
|
// 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 displayName = ReadString(el, "Name");
|
||||||
var lineId = ReadString(el, "UnsLineId");
|
var lineId = ReadString(el, "UnsLineId");
|
||||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||||
// DriverInstanceId / DeviceId copied straight from the row (null when absent / JSON null);
|
// v3: Equipment no longer binds a driver/device — it references raw tags via UnsTagReference.
|
||||||
// DeviceHost resolved from the device-host map by DeviceId — byte-parity with the composer's
|
// The EquipmentNode's DriverInstanceId/DeviceId/DeviceHost hooks (used by the multi-device FixedTree
|
||||||
// `e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
|
// partition path) are therefore always null now; Batch 4 owns the UNS↔Raw fan-out that lights them up.
|
||||||
var driverInstanceId = ReadString(el, "DriverInstanceId");
|
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
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
|
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
||||||
/// rebuild dedups by NodeId.
|
/// rebuild dedups by NodeId.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _nodeIdByDriverRef = new();
|
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
/// 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
|
/// 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).
|
/// map fans out 1:N because one ref can back several variables).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
|
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
/// <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
|
/// 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
|
/// 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>
|
/// 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>
|
/// <summary>
|
||||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
/// 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
|
/// 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).
|
/// back several conditions on identical machines).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeId =
|
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByAlarmNodeId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building
|
/// <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.
|
// the continuation: it runs off the actor thread, where raw Sender is unsafe to read.
|
||||||
var replyTo = Sender;
|
var replyTo = Sender;
|
||||||
entry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
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(
|
.ContinueWith(
|
||||||
t => t.IsCompletedSuccessfully
|
t => t.IsCompletedSuccessfully
|
||||||
? new NodeWriteResult(t.Result.Success, t.Result.Reason)
|
? 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
|
// 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'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).
|
// 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
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user