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>
|
||||
public string? Site { get; init; }
|
||||
|
||||
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
|
||||
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
|
||||
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
|
||||
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
|
||||
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
|
||||
// (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
|
||||
// effective-leaf uniqueness).
|
||||
/// <summary>Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).</summary>
|
||||
public IReadOnlyList<RawFolder> RawFolders { get; init; } = [];
|
||||
/// <summary>Gets the list of driver instances.</summary>
|
||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||
/// <summary>Gets the list of devices.</summary>
|
||||
public IReadOnlyList<Device> Devices { get; init; } = [];
|
||||
/// <summary>Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).</summary>
|
||||
public IReadOnlyList<TagGroup> TagGroups { get; init; } = [];
|
||||
/// <summary>Gets the list of UNS areas.</summary>
|
||||
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||
/// <summary>Gets the list of UNS lines.</summary>
|
||||
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||
/// <summary>Gets the list of equipment.</summary>
|
||||
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||
/// <summary>Gets the list of tags.</summary>
|
||||
/// <summary>Gets the list of raw tags.</summary>
|
||||
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
|
||||
/// with Tags; the collision rule checks both.</summary>
|
||||
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
|
||||
/// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).</summary>
|
||||
public IReadOnlyList<UnsTagReference> UnsTagReferences { get; init; } = [];
|
||||
|
||||
/// <summary>Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
|
||||
/// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).</summary>
|
||||
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
||||
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||
/// <summary>Gets the list of poll groups.</summary>
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
|
||||
@@ -36,14 +36,20 @@ public static class DraftSnapshotFactory
|
||||
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
|
||||
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
||||
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
|
||||
// v3: Namespace entity retired — no namespace list to materialize.
|
||||
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
|
||||
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
|
||||
// effective-leaf uniqueness).
|
||||
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
|
||||
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
|
||||
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
|
||||
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
|
||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||
ActiveReservations = await db.ExternalIdReservations
|
||||
|
||||
@@ -32,33 +32,110 @@ public static class DraftValidator
|
||||
ValidateEquipmentUuidImmutability(draft, errors);
|
||||
ValidateReservationPreflight(draft, errors);
|
||||
ValidateEquipmentIdDerivation(draft, errors);
|
||||
ValidateNoEquipmentSignalNameCollision(draft, errors);
|
||||
|
||||
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
|
||||
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
|
||||
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
|
||||
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
|
||||
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
|
||||
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
||||
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
||||
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
||||
{
|
||||
// v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
|
||||
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
|
||||
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
|
||||
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
|
||||
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
|
||||
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
return new RawPathResolver(folders, drivers, devices, groups);
|
||||
}
|
||||
|
||||
foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
|
||||
/// <summary>v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
|
||||
/// RawPath segment — no <c>/</c>, no leading/trailing whitespace, non-empty — because <c>/</c> is the
|
||||
/// RawPath separator and would corrupt the NodeId. Uses <see cref="RawPaths.ValidateSegment"/>, the same
|
||||
/// charset authority the path builder enforces.</summary>
|
||||
private static void ValidateRawNameCharset(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
void Check(string kind, string id, string name)
|
||||
{
|
||||
var items = g.ToList();
|
||||
if (items.Count <= 1) continue;
|
||||
var f = items[0];
|
||||
errors.Add(new("EquipmentSignalNameCollision",
|
||||
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
|
||||
"a VirtualTag Name must be unique within an equipment",
|
||||
f.Eq));
|
||||
var error = RawPaths.ValidateSegment(name);
|
||||
if (error is not null)
|
||||
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
|
||||
}
|
||||
|
||||
foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
|
||||
foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
|
||||
foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
|
||||
foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
|
||||
foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
|
||||
}
|
||||
|
||||
/// <summary>v3: a historized tag's EFFECTIVE historian tagname (the <c>historianTagname</c> override
|
||||
/// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
|
||||
/// A longer name is a deploy error telling the author to set a shorter <c>historianTagname</c> override,
|
||||
/// never a silent truncation.</summary>
|
||||
private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
const int MaxHistorianTagname = 255;
|
||||
var resolver = BuildRawPathResolver(draft);
|
||||
foreach (var t in draft.Tags)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
if (!intent.IsHistorized) continue;
|
||||
// Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
|
||||
// a separate rule/charset check flags that; nothing to length-check here).
|
||||
var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
if (effective is null || effective.Length <= MaxHistorianTagname) continue;
|
||||
errors.Add(new("HistorianTagnameTooLong",
|
||||
$"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
|
||||
"set a shorter 'historianTagname' override in its TagConfig",
|
||||
t.TagId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
|
||||
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
|
||||
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
|
||||
/// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
|
||||
/// authoring check never saw is caught before it ships.</summary>
|
||||
private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
|
||||
// (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
|
||||
var byLeaf = new Dictionary<(string Eq, string Name), List<string>>();
|
||||
void Add(string equipmentId, string? effectiveName, string source)
|
||||
{
|
||||
if (string.IsNullOrEmpty(effectiveName)) return;
|
||||
var key = (equipmentId, effectiveName);
|
||||
if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List<string>();
|
||||
list.Add(source);
|
||||
}
|
||||
|
||||
foreach (var r in draft.UnsTagReferences)
|
||||
{
|
||||
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
|
||||
}
|
||||
foreach (var v in draft.VirtualTags)
|
||||
Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
|
||||
foreach (var a in draft.ScriptedAlarms)
|
||||
Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
|
||||
|
||||
foreach (var ((eq, name), sources) in byLeaf)
|
||||
{
|
||||
if (sources.Count <= 1) continue;
|
||||
errors.Add(new("UnsEffectiveNameCollision",
|
||||
$"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
|
||||
string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
|
||||
eq));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -214,15 +214,21 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
|
||||
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
|
||||
// Running servers are untouched either way — the gate only sees re-deploys.
|
||||
// v3: a raw Tag's driver is resolved via its Device (Tag no longer carries EquipmentId /
|
||||
// DriverInstanceId). Inspect EVERY raw tag's TagConfig against its device's driver type.
|
||||
var driverTypeById = draft.DriverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||
var driverByDevice = draft.Devices
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
|
||||
var tagConfigWarnings = new List<(string TagId, string Text)>();
|
||||
foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null))
|
||||
foreach (var t in draft.Tags)
|
||||
{
|
||||
if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue;
|
||||
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
|
||||
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
|
||||
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
|
||||
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}"));
|
||||
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
|
||||
}
|
||||
if (tagConfigWarnings.Count > 0)
|
||||
{
|
||||
|
||||
@@ -29,16 +29,23 @@ public static class ConfigComposer
|
||||
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
|
||||
OtOpcUaConfigDbContext db, CancellationToken ct = default)
|
||||
{
|
||||
// v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
|
||||
// → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
|
||||
// projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
|
||||
// PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
|
||||
// RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
|
||||
var snapshot = new
|
||||
{
|
||||
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
|
||||
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
|
||||
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
|
||||
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
|
||||
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
|
||||
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
|
||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
|
||||
Namespaces = await db.Namespaces.AsNoTracking().OrderBy(x => x.NamespaceId).ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
|
||||
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
|
||||
|
||||
@@ -297,41 +297,24 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||
|
||||
/// <summary>UNS-aware overload that doesn't supply tags.</summary>
|
||||
/// <param name="unsAreas">The UNS areas.</param>
|
||||
/// <param name="unsLines">The UNS lines.</param>
|
||||
/// <param name="equipment">The equipment.</param>
|
||||
/// <param name="driverInstances">The driver instances.</param>
|
||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||
/// <param name="devices">The per-device rows used to resolve each equipment's <c>DeviceHost</c>. <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<Device>? devices = null) =>
|
||||
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), devices: devices);
|
||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
||||
|
||||
/// <summary>
|
||||
/// Composes the address space build plan from the configuration entities.
|
||||
/// Composes the address space build plan from the v3 configuration entities.
|
||||
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
|
||||
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
|
||||
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
|
||||
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
|
||||
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
|
||||
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
|
||||
/// </summary>
|
||||
/// <param name="unsAreas">The UNS areas.</param>
|
||||
/// <param name="unsLines">The UNS lines.</param>
|
||||
/// <param name="equipment">The equipment.</param>
|
||||
/// <param name="driverInstances">The driver instances.</param>
|
||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||
/// <param name="tags">The tags.</param>
|
||||
/// <param name="namespaces">The namespaces.</param>
|
||||
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
|
||||
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
|
||||
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
||||
/// <param name="devices">The per-device rows (<c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON) used to resolve
|
||||
/// each equipment's <c>DeviceHost</c> from its bound <c>DeviceId</c>. <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
@@ -339,28 +322,12 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<Tag> tags,
|
||||
IReadOnlyList<Namespace> namespaces,
|
||||
IReadOnlyList<VirtualTag>? virtualTags = null,
|
||||
IReadOnlyList<Script>? scripts = null,
|
||||
IReadOnlyList<Device>? devices = null)
|
||||
IReadOnlyList<Script>? scripts = null)
|
||||
{
|
||||
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
|
||||
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
|
||||
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
|
||||
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
|
||||
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
|
||||
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
|
||||
// side's last-wins). DeviceId is DB-unique so a dupe is defensive-only.
|
||||
var deviceHostById = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var d in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
|
||||
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
|
||||
}
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
||||
@@ -373,18 +340,10 @@ public static class AddressSpaceComposer
|
||||
|
||||
var nodes = equipment
|
||||
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
||||
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
|
||||
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
|
||||
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
|
||||
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
|
||||
// bound device's config (null when there's no device or no parseable HostAddress).
|
||||
.Select(e => new EquipmentNode(
|
||||
e.EquipmentId,
|
||||
e.Name,
|
||||
e.UnsLineId,
|
||||
DriverInstanceId: e.DriverInstanceId,
|
||||
DeviceId: e.DeviceId,
|
||||
DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)))
|
||||
// DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
|
||||
// EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
|
||||
// UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
|
||||
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
|
||||
.ToList();
|
||||
|
||||
var plans = driverInstances
|
||||
@@ -397,55 +356,10 @@ public static class AddressSpaceComposer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
||||
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
||||
|
||||
// Equipment tags = a Tag bound to an Equipment (non-null EquipmentId) whose driver's namespace
|
||||
// is Equipment-kind. FullName is the driver-side wire reference pulled from TagConfig — it
|
||||
// becomes the variable's NodeId + read/write routing key. Galaxy points are ordinary equipment
|
||||
// tags now (GalaxyMxGateway is a standard Equipment-kind driver), so no driver-type exception.
|
||||
var equipmentTags = tags
|
||||
.Where(t => t.EquipmentId is not null)
|
||||
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
||||
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
||||
&& ns.Kind == NamespaceKind.Equipment)
|
||||
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
|
||||
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Select(t =>
|
||||
{
|
||||
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
|
||||
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
|
||||
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
|
||||
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
return new EquipmentTagPlan(
|
||||
TagId: t.TagId,
|
||||
EquipmentId: t.EquipmentId!,
|
||||
DriverInstanceId: t.DriverInstanceId,
|
||||
FolderPath: t.FolderPath ?? string.Empty,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
FullName: intent.FullName,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
||||
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
|
||||
// scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused
|
||||
// across N identical machines resolves to N machine-specific dependency graphs.
|
||||
var baseByEquip = equipmentTags
|
||||
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
||||
StringComparer.Ordinal);
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
|
||||
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
||||
// expression source. The {{equip}} token is substituted with the owning equipment's tag
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
@@ -62,17 +63,25 @@ public static class DeploymentArtifact
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("DriverInstances", out var arr)
|
||||
|| arr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
|
||||
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
|
||||
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
|
||||
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
|
||||
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
|
||||
var rawTagsByDriver = BuildRawTagsByDriver(root);
|
||||
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
||||
|
||||
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var spec = TryReadSpec(el);
|
||||
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
|
||||
if (spec is not null) result.Add(spec);
|
||||
}
|
||||
return result;
|
||||
@@ -83,6 +92,121 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
|
||||
/// Tags) into each driver's authored <see cref="RawTagEntry"/> list, keyed by DriverInstanceId. Each
|
||||
/// tag's RawPath is computed via the shared <see cref="RawPathResolver"/> (the single identity authority),
|
||||
/// so the deploy-decode side agrees byte-for-byte with the authoring/validation side. A tag whose
|
||||
/// device/driver chain is broken (unknown link) is dropped.</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>DriverInstanceId → authored raw tags (RawPath + TagConfig + WriteIdempotent + DeviceName).</returns>
|
||||
private static Dictionary<string, List<RawTagEntry>> BuildRawTagsByDriver(JsonElement root)
|
||||
{
|
||||
var byDriver = new Dictionary<string, List<RawTagEntry>>(StringComparer.Ordinal);
|
||||
|
||||
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
{
|
||||
var id = ReadString(el, "RawFolderId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||
{
|
||||
var id = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var deviceToDriver = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Devices"))
|
||||
{
|
||||
var id = ReadString(el, "DeviceId");
|
||||
var di = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||
var name = ReadString(el, "Name") ?? id!;
|
||||
devices[id!] = (di!, name);
|
||||
deviceToDriver[id!] = di!;
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||
{
|
||||
var id = ReadString(el, "TagGroupId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var name = ReadString(el, "Name");
|
||||
if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (!deviceToDriver.TryGetValue(deviceId!, out var driverInstanceId)) continue;
|
||||
var tagGroupId = ReadNullableString(el, "TagGroupId");
|
||||
var rawPath = resolver.TryBuildTagPath(deviceId!, tagGroupId, name!);
|
||||
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() ?? "{}" : "{}";
|
||||
// WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent).
|
||||
var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl)
|
||||
&& wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean();
|
||||
var deviceName = devices.TryGetValue(deviceId!, out var dev) ? dev.Name : string.Empty;
|
||||
|
||||
if (!byDriver.TryGetValue(driverInstanceId, out var list))
|
||||
byDriver[driverInstanceId] = list = new List<RawTagEntry>();
|
||||
list.Add(new RawTagEntry(rawPath, tagConfig, writeIdempotent, deviceName));
|
||||
}
|
||||
|
||||
// Deterministic ordering per driver (ordinal RawPath) so the injected config is byte-stable.
|
||||
foreach (var list in byDriver.Values)
|
||||
list.Sort((a, b) => string.CompareOrdinal(a.RawPath, b.RawPath));
|
||||
return byDriver;
|
||||
}
|
||||
|
||||
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
|
||||
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>DriverInstanceId → ordered device rows.</returns>
|
||||
private static Dictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> BuildDeviceRowsByDriver(JsonElement root)
|
||||
{
|
||||
var byDriver = new Dictionary<string, List<(string DeviceId, DriverDeviceConfigMerger.DeviceRow Row)>>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Devices"))
|
||||
{
|
||||
var id = ReadString(el, "DeviceId");
|
||||
var di = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||
var name = ReadString(el, "Name") ?? id!;
|
||||
var deviceConfig = ReadString(el, "DeviceConfig");
|
||||
if (!byDriver.TryGetValue(di!, out var list))
|
||||
byDriver[di!] = list = new List<(string, DriverDeviceConfigMerger.DeviceRow)>();
|
||||
list.Add((id!, new DriverDeviceConfigMerger.DeviceRow(name, deviceConfig)));
|
||||
}
|
||||
|
||||
return byDriver.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.OrderBy(x => x.DeviceId, StringComparer.Ordinal).Select(x => x.Row).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Enumerate an artifact array property's object elements (empty when absent/non-array).</summary>
|
||||
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, string property)
|
||||
{
|
||||
if (!root.TryGetProperty(property, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
yield break;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Object)
|
||||
yield return el;
|
||||
}
|
||||
|
||||
/// <summary>Reads a string property, coalescing a JSON <c>null</c> (or wrong type) to <see langword="null"/>.</summary>
|
||||
private static string? ReadNullableString(JsonElement el, string property) =>
|
||||
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
||||
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
|
||||
@@ -155,7 +279,10 @@ public static class DeploymentArtifact
|
||||
private static string? ReadString(JsonElement el, string property) =>
|
||||
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
|
||||
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
|
||||
private static DriverInstanceSpec? TryReadSpec(
|
||||
JsonElement el,
|
||||
IReadOnlyDictionary<string, List<RawTagEntry>> rawTagsByDriver,
|
||||
IReadOnlyDictionary<string, List<DriverDeviceConfigMerger.DeviceRow>> deviceRowsByDriver)
|
||||
{
|
||||
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
||||
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
||||
@@ -166,7 +293,7 @@ public static class DeploymentArtifact
|
||||
var enabled = !el.TryGetProperty("Enabled", out var enEl)
|
||||
|| enEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)
|
||||
|| enEl.GetBoolean();
|
||||
var config = ReadString(el, "DriverConfig");
|
||||
var driverConfig = ReadString(el, "DriverConfig");
|
||||
var clusterId = ReadString(el, "ClusterId");
|
||||
// Per-instance resilience overrides (DriverInstance.ResilienceConfig column). Optional/nullable —
|
||||
// absent means tier defaults; layered onto the tier by DriverResilienceOptionsParser at spawn.
|
||||
@@ -174,13 +301,20 @@ public static class DeploymentArtifact
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
||||
|
||||
// v3 endpoint→DeviceConfig + RawTags injection: fold DriverConfig + per-device DeviceConfig + the
|
||||
// driver's authored raw tags into the single config the factory binds from (single-endpoint drivers
|
||||
// get their sole DeviceConfig merged up; multi-device drivers get a reconstructed Devices[] array).
|
||||
var devices = deviceRowsByDriver.TryGetValue(id!, out var dr) ? dr : (IReadOnlyList<DriverDeviceConfigMerger.DeviceRow>)Array.Empty<DriverDeviceConfigMerger.DeviceRow>();
|
||||
var rawTags = rawTagsByDriver.TryGetValue(id!, out var rt) ? rt : (IReadOnlyList<RawTagEntry>)Array.Empty<RawTagEntry>();
|
||||
var mergedConfig = DriverDeviceConfigMerger.Merge(driverConfig, devices, rawTags);
|
||||
|
||||
return new DriverInstanceSpec(
|
||||
DriverInstanceRowId: rowId,
|
||||
DriverInstanceId: id!,
|
||||
Name: name ?? id!,
|
||||
DriverType: type!,
|
||||
Enabled: enabled,
|
||||
DriverConfig: config ?? "{}",
|
||||
DriverConfig: mergedConfig,
|
||||
ClusterId: clusterId,
|
||||
ResilienceConfig: resilienceConfig);
|
||||
}
|
||||
@@ -208,13 +342,14 @@ public static class DeploymentArtifact
|
||||
|
||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||
// DeviceId → connection host, resolved from the artifact's Devices array via the SAME shared
|
||||
// helper the composer uses, so each EquipmentNode.DeviceHost is byte-parity-equal across seams.
|
||||
var deviceHostById = BuildDeviceHostMap(root);
|
||||
var equipment = ReadArray(root, "Equipment", el => ReadEquipmentNode(el, deviceHostById));
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||
// v3 DARK address space (Batch 1): NO equipment-tag variable plans materialize — the raw + UNS
|
||||
// variable nodes are lit up in Batch 4 (the dual namespace + UNS fan-out). EquipmentTags is
|
||||
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
|
||||
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
|
||||
@@ -326,21 +461,18 @@ public static class DeploymentArtifact
|
||||
lineToArea[lineId!] = areaId!;
|
||||
}
|
||||
}
|
||||
// Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's
|
||||
// cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster.
|
||||
// v3: Equipment carries no ClusterId AND no driver binding — it is attributed solely by its
|
||||
// UNS line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
|
||||
if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in eq.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||
var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
var inByDriver = di is not null && driverIds.Contains(di);
|
||||
var inByLine = di is null && lineId is not null
|
||||
&& lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine);
|
||||
if (inByDriver || inByLine) equipmentIds.Add(id!);
|
||||
if (lineId is not null && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine))
|
||||
equipmentIds.Add(id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,119 +508,6 @@ public static class DeploymentArtifact
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
/// <summary>
|
||||
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
||||
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
|
||||
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
|
||||
/// <c>AddressSpaceComposer.Compose</c>'s equipment filter — so the compose-side + artifact-decode
|
||||
/// plans agree on the same set of tags. FullName is read from each tag's TagConfig blob
|
||||
/// (top-level "FullName" field).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentTagPlan>();
|
||||
|
||||
// namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0);
|
||||
// tolerate the string form too.
|
||||
var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var el in nsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
|
||||
var isEquipment = kindEl.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => kindEl.GetInt32() == 0, // NamespaceKind.Equipment = 0
|
||||
JsonValueKind.String => string.Equals(kindEl.GetString(), "Equipment", StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
if (isEquipment) equipmentNamespaces.Add(id!);
|
||||
}
|
||||
|
||||
// driverInstanceId → namespaceId.
|
||||
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var el in diArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
|
||||
driverToNamespace[id!] = ns!;
|
||||
}
|
||||
|
||||
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
|
||||
foreach (var el in tagsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
// Equipment tags REQUIRE a non-null EquipmentId (the inverse of the Galaxy filter).
|
||||
if (!el.TryGetProperty("EquipmentId", out var eqEl) || eqEl.ValueKind == JsonValueKind.Null) continue;
|
||||
var equipmentId = eqEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(equipmentId)) continue;
|
||||
|
||||
var tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
|
||||
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
|
||||
? fpEl.GetString() : null;
|
||||
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() : null;
|
||||
// AccessLevel → Writable. ConfigComposer serialises the TagAccessLevel enum WITHOUT a
|
||||
// string converter, so it lands as a number (Read = 0, ReadWrite = 1); tolerate the string
|
||||
// form ("ReadWrite") too — same defensive both-forms parse as the Kind gate above. MUST match
|
||||
// AddressSpaceComposer's `AccessLevel == TagAccessLevel.ReadWrite` exactly (byte-parity). A missing
|
||||
// field defaults to non-writable (read-only).
|
||||
var writable = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => alEl.GetInt32() == (int)TagAccessLevel.ReadWrite,
|
||||
JsonValueKind.String => string.Equals(alEl.GetString(), nameof(TagAccessLevel.ReadWrite), StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
||||
// Equipment-kind namespace only — byte-parity with the composer's pure
|
||||
// `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are
|
||||
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
|
||||
if (!equipmentNamespaces.Contains(nsId)) continue;
|
||||
|
||||
// Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
|
||||
// (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
|
||||
// consumes, so the artifact-decode plan stays byte-equal with the composer's.
|
||||
var intent = TagConfigIntent.Parse(tagConfig);
|
||||
result.Add(new EquipmentTagPlan(
|
||||
TagId: tagId!,
|
||||
EquipmentId: equipmentId!,
|
||||
DriverInstanceId: di!,
|
||||
FolderPath: folder ?? string.Empty,
|
||||
Name: name!,
|
||||
DataType: dataType ?? "BaseDataType",
|
||||
FullName: intent.FullName,
|
||||
Writable: writable,
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
|
||||
if (byEquipment != 0) return byEquipment;
|
||||
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
|
||||
if (byFolder != 0) return byFolder;
|
||||
return string.CompareOrdinal(a.Name, b.Name);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
||||
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
|
||||
@@ -712,29 +731,7 @@ public static class DeploymentArtifact
|
||||
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
|
||||
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
|
||||
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> so the artifact-decode side
|
||||
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
|
||||
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>The resolved DeviceId → host map (host may be null when a device has no parseable HostAddress).</returns>
|
||||
private static IReadOnlyDictionary<string, string?> BuildDeviceHostMap(JsonElement root)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
if (!root.TryGetProperty("Devices", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return map;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) continue;
|
||||
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||
{
|
||||
var id = ReadString(el, "EquipmentId");
|
||||
// DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine
|
||||
@@ -743,19 +740,10 @@ public static class DeploymentArtifact
|
||||
var displayName = ReadString(el, "Name");
|
||||
var lineId = ReadString(el, "UnsLineId");
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
// DriverInstanceId / DeviceId copied straight from the row (null when absent / JSON null);
|
||||
// DeviceHost resolved from the device-host map by DeviceId — byte-parity with the composer's
|
||||
// `e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
|
||||
var driverInstanceId = ReadString(el, "DriverInstanceId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var deviceHost = deviceId is null ? null : deviceHostById.GetValueOrDefault(deviceId);
|
||||
return new EquipmentNode(
|
||||
id!,
|
||||
displayName ?? id!,
|
||||
lineId ?? string.Empty,
|
||||
DriverInstanceId: driverInstanceId,
|
||||
DeviceId: deviceId,
|
||||
DeviceHost: deviceHost);
|
||||
// v3: Equipment no longer binds a driver/device — it references raw tags via UnsTagReference.
|
||||
// The EquipmentNode's DriverInstanceId/DeviceId/DeviceHost hooks (used by the multi-device FixedTree
|
||||
// partition path) are therefore always null now; Batch 4 owns the UNS↔Raw fan-out that lights them up.
|
||||
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
||||
}
|
||||
|
||||
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
||||
|
||||
@@ -112,7 +112,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
||||
/// rebuild dedups by NodeId.
|
||||
/// </summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
||||
@@ -123,14 +123,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
||||
/// map fans out 1:N because one ref can back several variables).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
|
||||
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
|
||||
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
||||
@@ -142,7 +142,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// single driver alarm), so this is a flat 1:1 map (the forward map fans out 1:N because one ref can
|
||||
/// back several conditions on identical machines).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeId =
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByAlarmNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building
|
||||
@@ -1094,7 +1094,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// the continuation: it runs off the actor thread, where raw Sender is unsafe to read.
|
||||
var replyTo = Sender;
|
||||
entry.Actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
||||
new DriverInstanceActor.WriteAttribute(target.FullName, msg.Value!), TimeSpan.FromSeconds(8))
|
||||
new DriverInstanceActor.WriteAttribute(target.RawPath, msg.Value!), TimeSpan.FromSeconds(8))
|
||||
.ContinueWith(
|
||||
t => t.IsCompletedSuccessfully
|
||||
? new NodeWriteResult(t.Result.Success, t.Result.Reason)
|
||||
@@ -1164,7 +1164,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
|
||||
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
|
||||
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
|
||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.FullName, msg.Comment, msg.OperatorUser));
|
||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser));
|
||||
}
|
||||
|
||||
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
|
||||
|
||||
Reference in New Issue
Block a user