diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index b445768d..c3047afc 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -307,76 +307,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI` ### Why -Today each VirtualTag bound to a script typically needs its own near-duplicate -script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`). -The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at -a single template script, and each resolves the token to its own equipment's tag -base prefix at deploy time. No schema change is required — sharing a `Script` -record across VirtualTags already works; `{{equip}}` is what makes the shared -script resolve per-equipment. +Each VirtualTag bound to a script would otherwise need its own near-duplicate +script because tag paths are hard-coded absolute RawPaths (e.g. +`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/` token breaks this coupling: +point many VirtualTags' `ScriptId` at a single template script, and each resolves +the token through **its own equipment's tag references** at deploy time. No schema +change is required — sharing a `Script` record across VirtualTags already works; +`{{equip}}/` is what makes the shared script resolve per-equipment. ### Before / after -**Before — one script per machine:** +**Before — one script per machine (hard-coded RawPath):** ```csharp // Script "Calc_TestMachine_001" — hard-coded, cannot reuse -return ctx.GetTag("TestMachine_001.Speed").Value; +return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value; ``` **After — one shared template:** ```csharp // Script "Calc_Speed" — works for any machine -return ctx.GetTag("{{equip}}.Speed").Value; +return ctx.GetTag("{{equip}}/Speed").Value; ``` -`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`. -At deploy, each VirtualTag receives its own expanded copy: -`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively. +`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and +each has a **tag reference** whose effective name is `Speed`. At deploy, each +VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that +equipment's referenced raw tag's RawPath. -### Token rules +### Token rules (v3) -- The token is `{{equip}}` (double braces, lowercase). +- The token joint is a **slash**: `{{equip}}/` (double-brace stem, + lowercase). `` is a **reference effective name** — the reference's + `DisplayNameOverride`, else the backing raw tag's `Name`. - It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument string literals** — comments, logger strings, and other code are untouched. -- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`, - `ctx.GetTag("{{equip}}.Sub.Field")`, etc. -- The token expands to the equipment's **tag base prefix** — the common - substring-before-the-first-dot of that equipment's configured driver-tag - `FullName` values. Example: tags `TestMachine_001.Speed` and - `TestMachine_001.Temp` → base `TestMachine_001`. +- The whole post-prefix literal content is the reference name, so effective names + with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`. +- `{{equip}}/` **resolves through the owning equipment's `UnsTagReference` + rows** (by effective name) to the backing raw tag's `RawPath`. Example: a + reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to + `Cell1/Modbus/Dev1/Speed`. +- Scripted-alarm predicate scripts resolve `{{equip}}/` the same way; alarm + **message-template** tokens follow the same resolution rule. ### Validation requirement -Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in -the AdminUI if the equipment does not have at least one configured driver tag, or -if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or -absent). The rejection message is surfaced as a clear validation error on the save -form. This check is enforced eagerly so that an unresolved `{{equip}}` token — -which would leave a path that resolves to nothing at runtime (Bad quality) — can -never reach the deployed artifact. +Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/` is +rejected in the AdminUI when `` is not one of the owning equipment's +reference effective names. The same rule runs at the deploy gate +(`DraftValidator.ValidateEquipReferenceResolution`, error code +`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the +authoring check never saw. The rejection names the script/alarm, the equipment, and +the missing ref — so an unresolved token can never reach the deployed artifact. ### Editor support In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's inline script panel): -- **Hover** — hovering a `{{equip}}` path literal shows an - *"Equipment-relative path — resolved at deploy"* note. -- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal - offers completion of attribute leaf names (the part after the first dot of known - tag references in the catalog). +- **Hover** — hovering a `{{equip}}/` path literal shows an + *"Equipment-relative path — resolved through the equipment's tag reference at + deploy"* note. +- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal + offers the owning equipment's **reference effective names** (needs an equipment + context; inert on the shared ScriptEdit page). +- **Diagnostics** — an unresolved `{{equip}}/` is flagged (`OTSCRIPT_EQUIPREF`) + identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*. ### Maintainer note -Substitution runs at the two compose seams — -`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans` -— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper, -**before** dependency extraction. The runtime, the static change-trigger -dependency graph, and the literal-only path rule are therefore all unchanged: -by the time they see the script, `{{equip}}` has been replaced with a concrete -tag-base prefix and the path is a normal string literal. +Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and +`DeploymentArtifact.ParseComposition` — via the shared +`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken` +helper, fed the equipment's reference map (effective name → RawPath) built by +`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs +**before** dependency extraction, so the runtime, the static change-trigger +dependency graph, and the literal-only path rule are unchanged: by the time they +see the script, `{{equip}}/` has been replaced with a concrete RawPath and +the path is a normal string literal. An unresolved ref is left un-substituted (the +validator rejects it first — substitution never throws). The two seams build the +reference map identically, so their plans stay byte-parity. --- diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs new file mode 100644 index 00000000..2a6eeb3d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs @@ -0,0 +1,94 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// Builds the per-equipment reference map — equipmentId → (effectiveName → backing-tag RawPath) — +/// the single authority both compose seams (AddressSpaceComposer + DeploymentArtifact) +/// and the draft validator use to resolve {{equip}}/<RefName> script paths. A reference's +/// effective name is its DisplayNameOverride else the backing raw tag's Name; the +/// RawPath is computed through the shared (the same identity authority the +/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree +/// byte-for-byte. +/// Input-shape agnostic + pure: callers flatten their references (EF entities on one side, +/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the +/// effective-name rule, and the collision policy live ONLY here. References are consumed in +/// UnsTagReferenceId-ordinal order and effective-name collisions keep the FIRST — a valid draft +/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two +/// seams parity-stable on any input. +/// +public static class EquipmentReferenceMap +{ + /// A flattened UNS tag reference: which equipment references which raw tag under an optional override. + /// Stable logical id — the ordering key for deterministic first-wins. + /// The referencing equipment. + /// The backing raw tag. + /// Optional effective-name override; = use the raw tag's Name. + public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride); + + /// The backing raw tag's identity inputs for its RawPath + effective name. + /// The raw tag's leaf name (also the default effective name). + /// The tag's owning device id. + /// The tag's owning tag-group id, or when directly under the device. + public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId); + + /// + /// Build the reference map. For each reference (ordered by UnsTagReferenceId), resolve the + /// backing tag's RawPath via and key it under the effective name within + /// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built + /// (broken chain) are skipped. Effective-name collisions within an equipment keep the first. + /// + /// The flattened UNS tag references (any order; sorted internally). + /// Backing raw tags keyed by TagId. + /// The shared RawPath resolver over the raw topology. + /// equipmentId → (effectiveName → RawPath). Equipments with no resolvable reference are absent. + public static IReadOnlyDictionary> Build( + IEnumerable references, + IReadOnlyDictionary tagsById, + RawPathResolver resolver) + { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(tagsById); + ArgumentNullException.ThrowIfNull(resolver); + + var byEquip = new Dictionary>(StringComparer.Ordinal); + + foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal)) + { + if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue; + if (!tagsById.TryGetValue(r.TagId, out var tag)) continue; + var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!; + if (string.IsNullOrEmpty(effectiveName)) continue; + var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name); + if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names) + + if (!byEquip.TryGetValue(r.EquipmentId, out var map)) + byEquip[r.EquipmentId] = map = new Dictionary(StringComparer.Ordinal); + map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions) + } + + return byEquip.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyDictionary)kv.Value, + StringComparer.Ordinal); + } + + /// The set of effective names a single equipment's references contribute (for the authoring + /// gate + Monaco completion, which need only the names — not the RawPaths). + /// The flattened UNS tag references for (typically) one equipment. + /// Backing raw tag Name keyed by TagId. + /// The distinct effective names, ordinal. + public static IReadOnlySet EffectiveNames( + IEnumerable references, + IReadOnlyDictionary tagNameById) + { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(tagNameById); + var names = new HashSet(StringComparer.Ordinal); + foreach (var r in references) + { + var effectiveName = r.DisplayNameOverride + ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName); + } + return names; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs index d29894ca..be647250 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs @@ -3,19 +3,29 @@ using System.Text.RegularExpressions; namespace ZB.MOM.WW.OtOpcUa.Commons.Types; /// -/// Helpers for equipment-relative virtual-tag script paths. The reserved token -/// {{equip}} inside a ctx.GetTag/ctx.SetVirtualTag path literal is -/// replaced at the compose seams with the owning equipment's tag base prefix (derived -/// from its child-tag FullNames). Pure + regex-based (no Roslyn) so the OpcUaServer -/// composer and the Runtime artifact-decode path can both share it. Also the single home -/// for the ctx.GetTag("…") dependency-ref extraction those two seams used to -/// duplicate. +/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token +/// {{equip}}/<RefName> inside a ctx.GetTag/ctx.SetVirtualTag path literal is +/// replaced at the compose seams (AddressSpaceComposer + DeploymentArtifact) with the +/// backing raw tag's RawPath, resolved through the owning equipment's +/// UnsTagReference rows by effective name (<RefName>). +/// v3 syntax: the token joint is a slash{{equip}}/<RefName> — replacing +/// the v2 dot-prefix derivation ({{equip}}.X). <RefName> is a reference's effective name +/// (its DisplayNameOverride else the backing raw tag's Name). An unresolved +/// <RefName> is left un-substituted (substitution never throws); the deploy-time validator + +/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant +/// the editor accepts ⇔ publish accepts. +/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can +/// both share it. Also the single home for the ctx.GetTag("…") dependency-ref extraction those +/// two seams used to duplicate. /// public static class EquipmentScriptPaths { - /// The reserved equipment-base token. + /// The reserved equipment token stem. public const string EquipToken = "{{equip}}"; + /// The reserved equipment reference-relative prefix — the token stem plus the slash joint. + public const string EquipTokenPrefix = "{{equip}}/"; + // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these. private static readonly Regex GetTagRefRegex = new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); @@ -32,6 +42,14 @@ public static class EquipmentScriptPaths private static readonly Regex PathLiteralRegex = new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled); + // {{equip}}/ occurrence in FREE TEXT (message templates). runs until the next + // delimiter that cannot appear in an intra-literal RawPath segment used inline in a template — + // whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may + // contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the + // free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.) + private static readonly Regex EquipRefTextRegex = + new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled); + // A pure pass-through virtual-tag body: exactly `return ctx.GetTag("").Value;` // (whitespace-insensitive). Captures for relay→alias conversion. Anything with extra // statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay. @@ -46,44 +64,89 @@ public static class EquipmentScriptPaths !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal); /// - /// Equipment tag base = the single shared substring-before-first-dot across the - /// equipment's child-tag FullNames. Returns null when there are no usable - /// FullNames or they don't agree on one prefix (equipment spanning multiple objects). + /// Replace each {{equip}}/<RefName> reference-relative path with the backing raw tag's + /// RawPath from (effective name → RawPath), inside + /// ctx.GetTag/ctx.SetVirtualTag path literals only. A path literal whose content is + /// {{equip}}/<RefName> is treated as a single reference: <RefName> is the + /// entire remainder after the prefix (so reference effective names may contain internal spaces). + /// Identity when is null/empty, is empty, + /// or the token is absent (so every existing script — none of which use the token — is byte-unchanged). + /// An <RefName> absent from the map is left un-substituted (never throws) — the validator + /// rejects it first at deploy. /// - /// The equipment's child-tag driver FullNames. - /// The shared base prefix, or null when none/ambiguous. - public static string? DeriveEquipmentBase(IEnumerable childFullNames) + /// The script source. + /// The equipment's reference map: effective name → backing-tag RawPath. + /// The source with each resolvable {{equip}}/<RefName> substituted inside path literals. + public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary referenceMap) { - string? found = null; - foreach (var fn in childFullNames) - { - if (string.IsNullOrWhiteSpace(fn)) continue; - var dot = fn.IndexOf('.'); - var prefix = dot < 0 ? fn : fn.Substring(0, dot); - if (prefix.Length == 0) continue; - if (found is null) found = prefix; - else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null; - } - return found; + if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source; + if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source; + return PathLiteralRegex.Replace(source, m => + m.Groups[1].Value + + SubstituteContent(m.Groups[2].Value, referenceMap) + + m.Groups[3].Value); + } + + // Substitute the reference-relative token inside a single path-literal's content. The content is one + // whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name. + private static string SubstituteContent(string content, IReadOnlyDictionary referenceMap) + { + if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content; + var refName = content.Substring(EquipTokenPrefix.Length); + if (refName.Length == 0) return content; + return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content; } /// - /// Replace {{equip}} with inside - /// ctx.GetTag/ctx.SetVirtualTag path literals only. Identity when - /// is null/empty or the token is absent (so every existing - /// script — none of which use the token — is byte-unchanged). + /// Distinct <RefName> values used via {{equip}}/<RefName> inside + /// ctx.GetTag/ctx.SetVirtualTag path literals, in first-seen order. Scoped to the SAME + /// path literals operates on (so a token in a comment / logger + /// string is not reported), and the entire post-prefix remainder is the reference name (matching + /// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what + /// resolves. Feeds the deploy-gate + authoring rejection + editor completion. /// - /// The script source. - /// The equipment base prefix, or null/empty for no substitution. - /// The source with the token substituted inside path literals. - public static string SubstituteEquipmentToken(string source, string? equipBase) + /// The virtual-tag / scripted-alarm predicate script source. + /// Distinct reference names, first-seen order; empty when none / no token. + public static IReadOnlyList ExtractEquipReferenceNames(string? scriptSource) { - if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source; - if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source; - return PathLiteralRegex.Replace(source, m => - m.Groups[1].Value - + m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal) - + m.Groups[3].Value); + if (string.IsNullOrEmpty(scriptSource) + || !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal)) + return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (Match m in PathLiteralRegex.Matches(scriptSource)) + { + var content = m.Groups[2].Value; + if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue; + var refName = content.Substring(EquipTokenPrefix.Length); + if (refName.Length > 0 && seen.Add(refName)) result.Add(refName); + } + return result; + } + + /// + /// Distinct <RefName> values used via {{equip}}/<RefName> in FREE TEXT — the + /// scripted-alarm MessageTemplate, which is not a path literal — in first-seen order. Best-effort: + /// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so + /// a space-bearing effective name used inline in a template resolves only up to its first space (a + /// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule + /// for alarm message tokens. + /// + /// The free text (e.g. a scripted-alarm message template). + /// Distinct reference names, first-seen order; empty when none / no token. + public static IReadOnlyList ExtractEquipReferenceNamesFromText(string? text) + { + if (string.IsNullOrEmpty(text) + || !text.Contains(EquipTokenPrefix, StringComparison.Ordinal)) + return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (Match m in EquipRefTextRegex.Matches(text)) + { + var refName = m.Groups[1].Value; + if (refName.Length > 0 && seen.Add(refName)) result.Add(refName); + } + return result; } /// @@ -115,10 +178,11 @@ public static class EquipmentScriptPaths /// {{equip}} double-brace form is excluded by the token regex. Deterministic so the live /// composer (AddressSpaceComposer) and the artifact-decode mirror (DeploymentArtifact) /// produce the exact same ordered list — the byte-parity contract EquipmentScriptedAlarmPlan - /// equality depends on. Scripted alarms do NOT use {{equip}} substitution (only virtual - /// tags do) — pass the predicate source as-is. + /// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two + /// compose seams substitute {{equip}}/<RefName> before extraction, identically), so the + /// merged refs are resolved RawPaths. /// - /// The resolved predicate script source. + /// The resolved (substituted) predicate script source. /// The alarm message template carrying {TagPath} tokens. /// The merged, distinct, deterministically-ordered dependency refs. public static IReadOnlyList ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate) @@ -154,7 +218,7 @@ public static class EquipmentScriptPaths /// The virtual-tag script source to inspect. /// /// When the method returns , the captured GetTag path literal - /// (e.g. TestMachine_020.TestChangingInt or {{equip}}.Speed); + /// (e.g. TestMachine_020.TestChangingInt or {{equip}}/Speed); /// otherwise . /// /// 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 86174ef1..11d6e08c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -39,6 +39,7 @@ public static class DraftValidator ValidateRawNameCharset(draft, errors); ValidateHistorizedTagnameLength(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors); + ValidateEquipReferenceResolution(draft, errors); ValidateCalculationTags(draft, errors); return errors; } @@ -238,6 +239,64 @@ public static class DraftValidator } } + /// v3: every {{equip}}/<RefName> used in an equipment's VirtualTag script, + /// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning + /// equipment's UnsTagReference effective names (DisplayNameOverride else the backing raw + /// tag's Name) — the same set the compose seams substitute against. An unresolved <RefName> + /// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the + /// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic + + /// the authoring gate enforce (editor accepts ⇔ publish accepts). + private static void ValidateEquipReferenceResolution(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 → set of reference effective names ({{equip}}/ resolves through REFERENCES + // only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath). + var refNamesByEquip = new Dictionary>(StringComparer.Ordinal); + foreach (var r in draft.UnsTagReferences) + { + var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (string.IsNullOrEmpty(effective)) continue; + if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set)) + refNamesByEquip[r.EquipmentId] = set = new HashSet(StringComparer.Ordinal); + set.Add(effective!); + } + + var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal); + + void CheckRefs(string equipmentId, IEnumerable refNames, string source) + { + var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null; + foreach (var name in refNames) + { + if (have is not null && have.Contains(name)) continue; + errors.Add(new("EquipReferenceUnresolved", + $"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " + + $"no reference named '{name}'; add a reference with that effective name or correct the script.", + equipmentId)); + } + } + + foreach (var v in draft.VirtualTags) + { + var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src); + if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'"); + } + + foreach (var a in draft.ScriptedAlarms) + { + var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src) + .Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'"); + } + } + private static bool IsValidSegment(string? s) => s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor index 6b4e549d..d4c73da1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor @@ -29,6 +29,13 @@ [Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ShowToolbar { get; set; } = true; + /// + /// Owning-equipment context for equipment-relative {{equip}}/<RefName> completion + + /// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared + /// ScriptEdit page (where the token cannot resolve to a single equipment). + /// + [Parameter] public string? EquipmentId { get; set; } + /// /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic /// debounce). The marker DTO is not modelled yet — typed as object[] until a @@ -61,7 +68,8 @@ { value = Value ?? "", language = Language, - readOnly = ReadOnly + readOnly = ReadOnly, + equipmentId = EquipmentId }, _dotNetRef); _initialized = true; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor index 3917da3c..ab7a4868 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -99,7 +99,7 @@ Editing shared script "@_scriptName" — used by @_scriptUsageCount virtual tag(s). Changes affect all of them. - + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index e6f03fd9..d7004c2d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -23,13 +23,16 @@ public interface IScriptTagCatalog /// The resolved tag info, or when is not a known configured path. Task GetTagInfoAsync(string path, CancellationToken ct); - /// Distinct attribute leaf names — the substring after the first dot of each - /// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion, - /// which needs the per-equipment object prefix stripped. - /// Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded). + /// v3: the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the resolvable set for + /// {{equip}}/<RefName> completion + diagnostics, matching what the compose seams substitute + /// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has + /// no references. + /// The equipment whose references to list; blank returns empty. + /// Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded). /// Cancellation token. - /// Distinct leaf names. - Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct); + /// Distinct reference effective names. + Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct); } /// Resolved info for one configured tag/virtual-tag path (for hover). @@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory d } /// - public async Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) + public async Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct) { - var entries = await BuildEntriesAsync(ct); - var leaves = new HashSet(StringComparer.Ordinal); - foreach (var e in entries) + if (string.IsNullOrEmpty(equipmentId)) return Array.Empty(); + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + if (refs.Count == 0) return Array.Empty(); + + var tagIds = refs.Select(r => r.TagId).Distinct().ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + + // Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set). + var names = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) { - var dot = e.Path.IndexOf('.'); - if (dot < 0 || dot + 1 >= e.Path.Length) continue; - leaves.Add(e.Path.Substring(dot + 1)); + var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(eff)) names.Add(eff!); } - IEnumerable q = leaves; + + IEnumerable q = names; if (!string.IsNullOrWhiteSpace(filter)) q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 62f962a4..2c71031c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -1,15 +1,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; -public record DiagnoseRequest(string Code); +// EquipmentId (optional) carries the owning-equipment context the {{equip}}/ completion + +// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single +// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per +// owning equipment). +public record DiagnoseRequest(string Code, string? EquipmentId = null); public record DiagnoseResponse(IReadOnlyList Markers); public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn, int EndLineNumber, int EndColumn, string Message, string Code); -public record CompletionsRequest(string CodeText, int Line, int Column); +public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record CompletionsResponse(IReadOnlyList Items); public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0); -public record HoverRequest(string CodeText, int Line, int Column); +public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record HoverResponse(string? Markdown); public record SignatureHelpRequest(string CodeText, int Line, int Column); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 7fec4785..6783f56f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. var group = endpoints.MapGroup("/api/script-analysis") .RequireAuthorization(AdminUiPolicies.ConfigEditor); - group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); + group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 32331a0e..663ef22c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService } } + // ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring + // EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/ diagnostic is scoped to the + // SAME path literals the compose seams substitute (never a comment / logger string). + private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex = + new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", + System.Text.RegularExpressions.RegexOptions.Compiled); + + /// Diagnostics plus the v3 equipment-relative {{equip}}/<RefName> resolution check. + /// Runs the synchronous Roslyn/policy then, when an equipment context + catalog + /// are present, appends a marker for each {{equip}}/<RefName> whose <RefName> is + /// not one of the equipment's reference effective names — flagging exactly what the deploy gate + /// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish + /// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null. + /// The diagnose request (its EquipmentId carries the owning-equipment context). + /// The base diagnostics plus any unresolved-reference markers. + public async Task DiagnoseAsync(DiagnoseRequest req) + { + var baseResp = Diagnose(req); + if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code)) + return baseResp; + try + { + var code = Normalize(req.Code); + if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) + return baseResp; + + var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None); + var resolvable = new HashSet(names, StringComparer.Ordinal); + var markers = new List(baseResp.Markers); + + foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code)) + { + var content = m.Groups[2].Value; + if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue; + var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length); + if (refName.Length == 0 || resolvable.Contains(refName)) continue; + // Mark the whole literal content span (the {{equip}}/ path). + var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length); + markers.Add(ToUserMarker(code, span, + $"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " + + $"reference named '{refName}'. Add a matching reference or correct the path.", + "OTSCRIPT_EQUIPREF")); + } + return new DiagnoseResponse(markers.Distinct().ToList()); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers."); + return baseResp; + } + } + private static int Sev(DiagnosticSeverity s) => s switch { DiagnosticSeverity.Error => 8, @@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _)) { - const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}." - if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal)) + // v3: {{equip}}/ completes the owning equipment's UnsTagReference effective names + // (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null). + const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/" + if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal)) { - var leaves = await _catalog.GetEquipmentRelativeLeavesAsync( - pathPrefix.Substring(equipDot.Length), CancellationToken.None); - return new CompletionsResponse(leaves - .Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field")) + if (string.IsNullOrEmpty(req.EquipmentId)) + return new CompletionsResponse(Array.Empty()); + var names = await _catalog.GetEquipmentReferenceNamesAsync( + req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None); + return new CompletionsResponse(names + .Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field")) .ToList()); } @@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService { return new HoverResponse( $"**Equipment-relative path** `{Code(tagPath)}`\n\n" + - "The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed."); + "`{{equip}}/` resolves through the owning equipment's tag reference " + + "(by effective name) to the backing raw tag's path when the VirtualTag is deployed."); } var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 692df0ad..cb6c8df6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1226,29 +1226,45 @@ public sealed class UnsTreeService( } /// - /// When the bound script uses the reserved {{equip}} token, the owning equipment must have - /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result - /// when the token is present but no base can be derived; null otherwise (token absent, or a - /// base exists). The script source is fetched from the supplied (in-scope) context. + /// v3 (M1): when the bound script uses {{equip}}/<RefName>, every <RefName> must + /// resolve to one of the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the same set the compose seams + /// substitute against and the deploy gate (DraftValidator.ValidateEquipReferenceResolution) + /// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; null + /// otherwise (no slash-token, or all resolve). Replaces the v2 DeriveEquipmentBase(empty)→null + /// check, which rejected ALL {{equip}} VirtualTags with a now-impossible "add a driver tag" message. /// private static async Task ValidateEquipTokenAsync( OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct) { var src = await db.Scripts.Where(s => s.ScriptId == scriptId) .Select(s => s.SourceCode).FirstOrDefaultAsync(ct); - if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src); + if (used.Count == 0) return null; - // The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver - // tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here. - var fullNames = Array.Empty(); - if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null) + // The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name. + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + var tagIds = refs.Select(r => r.TagId).ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + var effectiveNames = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) { - return new UnsMutationResult(false, - $"Equipment '{equipmentId}' has no single tag base, so the " - + $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this " - + $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script."); + var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!); } - return null; + + var missing = used.Where(u => !effectiveNames.Contains(u)).ToList(); + if (missing.Count == 0) return null; + return new UnsMutationResult(false, + $"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} " + + $"but equipment '{equipmentId}' has no reference with that effective name. " + + "Add a matching reference on the Tags tab, or correct the script."); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js index 714a7e37..e90c63bc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js @@ -23,6 +23,11 @@ const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 }; + // The owning-equipment id is stamped onto each editor's model at createEditor time so the globally + // registered csharp providers (which only receive the model) can thread it into the {{equip}}/ + // completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there). + function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; } + function registerCSharpProviders() { monaco.languages.registerCompletionItemProvider("csharp", { triggerCharacters: [".", "(", "\""], @@ -31,7 +36,7 @@ const resp = await fetch("/api/script-analysis/completions", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return { suggestions: [] }; const data = await resp.json(); @@ -72,7 +77,7 @@ const resp = await fetch("/api/script-analysis/hover", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return null; const data = await resp.json(); @@ -149,7 +154,7 @@ const resp = await fetch("/api/script-analysis/diagnostics", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) + body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return []; const data = await resp.json(); @@ -192,6 +197,9 @@ // inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space. quickSuggestions: { other: true, comments: false, strings: true } }); + // Stamp the owning-equipment id on the model so the global csharp providers can thread it into the + // {{equip}}/ completion + diagnostics (null on the shared ScriptEdit page). + try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {} let diagTimer = null; const scheduleDiagnostics = function () { if (diagTimer) clearTimeout(diagTimer); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 1625aec6..24d46012 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -315,6 +315,11 @@ public static class AddressSpaceComposer /// The scripted alarms. /// The per-equipment virtual (calculated) tags. null = none. /// The scripts joined to by ScriptId for the expression. null = none. + /// The UNS tag references (equipment → raw tag). Feed the {{equip}}/<RefName> reference map. null = none. + /// The raw tags backing (RawPath computed via the shared resolver). null = none. + /// The raw-tree folders (RawPath ancestry). null = none. + /// The raw-tree devices (RawPath ancestry). null = none. + /// The raw-tree tag-groups (RawPath ancestry). null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, @@ -323,7 +328,12 @@ public static class AddressSpaceComposer IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList? virtualTags = null, - IReadOnlyList