From e81ac352ed1e0e241ab2ba2337c1d27e93d634dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 20:45:24 -0400 Subject: [PATCH] v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../Types/DriverDeviceConfigMerger.cs | 99 ++++++ .../Types/RawPathResolver.cs | 132 ++++++++ .../Validation/DraftSnapshot.cs | 23 +- .../Validation/DraftSnapshotFactory.cs | 8 +- .../Validation/DraftValidator.cs | 117 +++++-- .../AdminOperations/AdminOperationsActor.cs | 12 +- .../AdminOperations/ConfigComposer.cs | 9 +- .../AddressSpaceComposer.cs | 122 +------ .../Drivers/DeploymentArtifact.cs | 320 +++++++++--------- .../Drivers/DriverHostActor.cs | 12 +- .../RawPathResolverTests.cs | 113 +++++++ .../Drivers/DeploymentArtifactRawPathTests.cs | 89 +++++ 12 files changed, 749 insertions(+), 307 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/RawPathResolverTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawPathTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs new file mode 100644 index 00000000..b97756cf --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DriverDeviceConfigMerger.cs @@ -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; + +/// +/// v3 endpoint→DeviceConfig merge authority. Folds a driver's DriverConfig JSON +/// (protocol/channel-level settings) together with its per-Device DeviceConfig 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 (DeploymentArtifact, which builds each DriverInstanceSpec.DriverConfig) and — in +/// Batch 2 — the browse modal (which needs the same merged shape as the browser's configJson), +/// so the merge lives in exactly one place. +/// Contract. Output = the parsed DriverConfig object with, in order: +/// +/// When there is EXACTLY ONE device, its DeviceConfig keys shallow-merged up to the top +/// level (DeviceConfig wins) — so a single-endpoint driver's endpoint (Host/Port, +/// EndpointUrl, gateway address, …) lands where its options bind. Multiple devices do NOT +/// merge up (their endpoints are per-device, read from the array below). +/// A reconstructed Devices array — one object per device = its DeviceConfig keys +/// plus DeviceName = the device's entity Name (the RawPath device segment / +/// routing key). Multi-device drivers read this; single-endpoint +/// drivers ignore the unknown key. +/// A RawTags array = the driver's authored list, so +/// options.RawTags binds. +/// +/// 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 +/// DriverConfig or DeviceConfig degrades to an empty object for that member. +/// +public static class DriverDeviceConfigMerger +{ + private static readonly JsonSerializerOptions SerializeOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + }; + + /// The device rows feeding the merge: the entity Name (routing key) + schemaless DeviceConfig JSON. + /// The device's entity Name — the RawPath device segment and multi-device routing key. + /// The device's schemaless DeviceConfig JSON (endpoint + per-device settings). + public readonly record struct DeviceRow(string Name, string? DeviceConfig); + + /// Merge a driver's config + its devices' configs + its authored raw tags into one config blob. + /// The driver-level DriverConfig JSON (protocol/channel settings). + /// The driver's device rows, in deterministic order (the sole device merges up). + /// The driver's authored raw tags to inject as the RawTags array. + /// The single merged config JSON string the driver factory binds from. + public static string Merge( + string? driverConfigJson, + IReadOnlyList devices, + IReadOnlyList 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); + } + + /// Parse JSON into a mutable ; blank / non-object / malformed ⇒ a fresh empty object. + 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(); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs new file mode 100644 index 00000000..47feedfd --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPathResolver.cs @@ -0,0 +1,132 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// Resolves a raw Tag's v3 RawPath — the single identity string +/// <Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag> — 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 ). +/// Every path is built through , so segment charset (no +/// /, no lead/trail whitespace) is enforced in one place. A broken link (missing device / driver +/// lookup, or an invalid segment) yields — 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. +/// +public sealed class RawPathResolver +{ + private const int MaxAncestryDepth = 256; + + private readonly IReadOnlyDictionary _folders; + private readonly IReadOnlyDictionary _drivers; + private readonly IReadOnlyDictionary _devices; + private readonly IReadOnlyDictionary _groups; + + /// Initializes a new over the raw topology's ancestry maps. + /// RawFolderId → (ParentRawFolderId?, Name). parent = cluster root. + /// DriverInstanceId → (RawFolderId?, Name). folder = cluster root. + /// DeviceId → (DriverInstanceId, Name). + /// TagGroupId → (ParentTagGroupId?, Name). parent = directly under the device. + public RawPathResolver( + IReadOnlyDictionary folders, + IReadOnlyDictionary drivers, + IReadOnlyDictionary devices, + IReadOnlyDictionary groups) + { + ArgumentNullException.ThrowIfNull(folders); + ArgumentNullException.ThrowIfNull(drivers); + ArgumentNullException.ThrowIfNull(devices); + ArgumentNullException.ThrowIfNull(groups); + _folders = folders; + _drivers = drivers; + _devices = devices; + _groups = groups; + } + + /// + /// Build the RawPath prefix a driver contributes to every child tag: its folder ancestry + /// (root → leaf) followed by the driver name. Returns when the driver id is + /// unknown or any segment/link is invalid. + /// + /// The driver instance id. + /// The driver RawPath prefix, or on a broken/unknown chain. + 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; + } + } + + /// + /// Build a raw tag's full RawPath: + /// <driver-prefix>/<Device>/<TagGroup ancestry root→leaf>/<Tag>. + /// Returns when the device / driver lookup is missing or any segment/link is + /// invalid. + /// + /// The tag's owning device id. + /// The tag's owning tag-group id, or when directly under the device. + /// The tag's leaf name. + /// The tag's RawPath, or on a broken/unknown chain. + 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; + } + } + + /// Build a folder ancestry path (root → leaf) for the given leaf folder id (null = cluster root ⇒ blank). + private string? BuildFolderChain(string? leafFolderId) + { + if (leafFolderId is null) return null; + var names = new List(); + 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); + } + + /// Append a tag-group ancestry path (root → leaf) onto (null group ⇒ unchanged). + private string AppendGroupChain(string parentPath, string? leafGroupId) + { + if (leafGroupId is null) return parentPath; + var names = new List(); + 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; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs index 08f86aa3..9fa9dad7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs @@ -25,25 +25,36 @@ public sealed class DraftSnapshot /// 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). + /// Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation). + public IReadOnlyList RawFolders { get; init; } = []; /// Gets the list of driver instances. public IReadOnlyList DriverInstances { get; init; } = []; /// Gets the list of devices. public IReadOnlyList Devices { get; init; } = []; + /// Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation). + public IReadOnlyList TagGroups { get; init; } = []; /// Gets the list of UNS areas. public IReadOnlyList UnsAreas { get; init; } = []; /// Gets the list of UNS lines. public IReadOnlyList UnsLines { get; init; } = []; /// Gets the list of equipment. public IReadOnlyList Equipment { get; init; } = []; - /// Gets the list of tags. + /// Gets the list of raw tags. public IReadOnlyList Tags { get; init; } = []; - /// Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space - /// with Tags; the collision rule checks both. + /// 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). + public IReadOnlyList UnsTagReferences { get; init; } = []; + + /// Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf + /// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms). public IReadOnlyList VirtualTags { get; init; } = []; + /// Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set. + public IReadOnlyList ScriptedAlarms { get; init; } = []; /// Gets the list of poll groups. public IReadOnlyList PollGroups { get; init; } = []; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs index 7ba7e94c..2325944d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs @@ -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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index d49a61d9..097f56d1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -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 errors) + /// Builds the shared from the draft's raw topology so the + /// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers. + 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)) + /// v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal + /// RawPath segment — no /, no leading/trailing whitespace, non-empty — because / is the + /// RawPath separator and would corrupt the NodeId. Uses , the same + /// charset authority the path builder enforces. + private static void ValidateRawNameCharset(DraftSnapshot draft, List 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); + } + + /// v3: a historized tag's EFFECTIVE historian tagname (the historianTagname 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 historianTagname override, + /// never a silent truncation. + private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List 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)); + } + } + + /// 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 ({EquipmentId}/{EffectiveName}). + /// 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. + private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List 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>(); + 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(); + 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)); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index 645ee64e..6766c520 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -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) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs index 9ef81752..261b2ea7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs @@ -29,16 +29,23 @@ public static class ConfigComposer public static async Task 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), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index eff3d232..1625aec6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -297,41 +297,24 @@ public static class AddressSpaceComposer IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms) => - Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms, - Array.Empty(), Array.Empty()); - - /// UNS-aware overload that doesn't supply tags. - /// The UNS areas. - /// The UNS lines. - /// The equipment. - /// The driver instances. - /// The scripted alarms. - /// The per-device rows used to resolve each equipment's DeviceHost. null = none. - /// The composition result. - public static AddressSpaceComposition Compose( - IReadOnlyList unsAreas, - IReadOnlyList unsLines, - IReadOnlyList equipment, - IReadOnlyList driverInstances, - IReadOnlyList scriptedAlarms, - IReadOnlyList? devices = null) => - Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms, - Array.Empty(), Array.Empty(), devices: devices); + Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms); /// - /// Composes the address space build plan from the configuration entities. + /// Composes the address space build plan from the v3 configuration entities. + /// v3 DARK address space (Batch 1): equipment-tag variable nodes do NOT materialize — + /// EquipmentTags 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 + /// 's driver/device hooks are always null. This is the pure reference + /// mirror of DeploymentArtifact.ParseComposition (byte-parity target for the corpus tests). /// /// The UNS areas. /// The UNS lines. /// The equipment. /// The driver instances. /// The scripted alarms. - /// The tags. - /// The namespaces. - /// The Equipment-namespace virtual (calculated) tags. null = none. + /// The per-equipment virtual (calculated) tags. null = none. /// The scripts joined to by ScriptId for the expression. null = none. - /// The per-device rows (DeviceId + schemaless DeviceConfig JSON) used to resolve - /// each equipment's DeviceHost from its bound DeviceId. null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, @@ -339,28 +322,12 @@ public static class AddressSpaceComposer IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, - IReadOnlyList tags, - IReadOnlyList namespaces, IReadOnlyList? virtualTags = null, - IReadOnlyList