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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user