498 lines
28 KiB
C#
498 lines
28 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
|
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
|
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
|
/// its parent line id so the applier knows where to hang each equipment folder.
|
|
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
|
|
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
|
|
public sealed record Phase7CompositionResult(
|
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
|
|
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
|
|
{
|
|
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
|
|
/// <param name="equipmentNodes">The equipment nodes.</param>
|
|
/// <param name="driverInstancePlans">The driver instance plans.</param>
|
|
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
|
|
public Phase7CompositionResult(
|
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
|
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
|
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
|
{
|
|
}
|
|
|
|
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
|
|
/// <param name="unsAreas">The UNS areas.</param>
|
|
/// <param name="unsLines">The UNS lines.</param>
|
|
/// <param name="equipmentNodes">The equipment nodes.</param>
|
|
/// <param name="driverInstancePlans">The driver instance plans.</param>
|
|
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
|
|
public Phase7CompositionResult(
|
|
IReadOnlyList<UnsAreaProjection> unsAreas,
|
|
IReadOnlyList<UnsLineProjection> unsLines,
|
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
|
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
|
|
/// in an <c>Equipment</c>-kind namespace. Mirror of <see cref="GalaxyTags"/> for the UNS
|
|
/// equipment-signal path: <c>Phase7Applier.MaterialiseEquipmentTags</c> materialises each as
|
|
/// a Variable under its existing equipment folder. Declared as an init-only member defaulting
|
|
/// to empty (rather than a 7th positional parameter) so every existing convenience
|
|
/// constructor + call site keeps compiling unchanged; new producers set it via initializer.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
|
|
|
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
|
|
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
|
|
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
|
|
|
/// <summary>
|
|
/// Per-equipment scripted-alarm host plans — the richer analogue of the thin
|
|
/// <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
|
|
/// source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
|
|
/// <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
|
|
/// runtime alarm host can subscribe to every signal and evaluate the predicate. See
|
|
/// <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
|
|
/// constructor + call site keeps compiling unchanged.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
|
}
|
|
|
|
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
|
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
|
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
|
|
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
|
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
|
|
|
/// <summary>
|
|
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
|
|
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
|
|
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
|
|
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
|
|
/// that the Galaxy driver consumes when subscribing.
|
|
/// </summary>
|
|
public sealed record GalaxyTagPlan(
|
|
string TagId,
|
|
string DriverInstanceId,
|
|
string FolderPath,
|
|
string DisplayName,
|
|
string DataType,
|
|
string MxAccessRef);
|
|
|
|
/// <summary>
|
|
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
|
|
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
|
|
/// <see cref="TagId"/> (diff identity), the parent <see cref="EquipmentId"/> folder (already
|
|
/// materialised by <c>Phase7Applier.MaterialiseHierarchy</c>) the variable hangs under, the
|
|
/// optional <see cref="FolderPath"/> sub-folder, the leaf <see cref="Name"/> display, the OPC UA
|
|
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
|
|
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
|
|
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
|
|
/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal
|
|
/// analogue of <see cref="GalaxyTagPlan"/>.
|
|
/// </summary>
|
|
public sealed record EquipmentTagPlan(
|
|
string TagId,
|
|
string EquipmentId,
|
|
string DriverInstanceId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string FullName);
|
|
|
|
/// <summary>
|
|
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
|
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
|
|
/// <see cref="EquipmentTagPlan"/>: <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c>
|
|
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
|
|
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
|
|
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
|
|
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
|
|
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
|
|
/// the script source.
|
|
/// </summary>
|
|
public sealed record EquipmentVirtualTagPlan(
|
|
string VirtualTagId,
|
|
string EquipmentId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string Expression,
|
|
IReadOnlyList<string> DependencyRefs)
|
|
{
|
|
/// <summary>Structural equality: the auto-generated record equality would compare
|
|
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
|
|
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
|
|
/// so a no-op redeploy diffs empty.</summary>
|
|
public bool Equals(EquipmentVirtualTagPlan? other) =>
|
|
other is not null &&
|
|
VirtualTagId == other.VirtualTagId &&
|
|
EquipmentId == other.EquipmentId &&
|
|
FolderPath == other.FolderPath &&
|
|
Name == other.Name &&
|
|
DataType == other.DataType &&
|
|
Expression == other.Expression &&
|
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = new HashCode();
|
|
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
|
|
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
|
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
|
return hash.ToHashCode();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
|
|
/// predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
|
|
/// richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
|
|
/// spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
|
|
/// <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
|
|
/// <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
|
|
/// <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
|
|
/// <c>{TagPath}</c> token paths referenced in the message template (the reserved
|
|
/// <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> is carried (never
|
|
/// dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed
|
|
/// to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for
|
|
/// byte-parity, exactly like <see cref="EquipmentVirtualTagPlan"/>.
|
|
/// </summary>
|
|
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
|
|
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
|
|
/// <param name="Name">Operator-facing alarm name.</param>
|
|
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
|
|
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
|
|
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
|
|
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
|
|
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
|
|
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
|
|
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
|
|
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
|
|
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
|
|
public sealed record EquipmentScriptedAlarmPlan(
|
|
string ScriptedAlarmId,
|
|
string EquipmentId,
|
|
string Name,
|
|
string AlarmType,
|
|
int Severity,
|
|
string MessageTemplate,
|
|
string PredicateScriptId,
|
|
string PredicateSource,
|
|
IReadOnlyList<string> DependencyRefs,
|
|
bool HistorizeToAveva,
|
|
bool Retain,
|
|
bool Enabled)
|
|
{
|
|
/// <summary>Structural equality: the auto-generated record equality would compare
|
|
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every alarm as
|
|
/// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy
|
|
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
|
|
/// <remarks>
|
|
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
|
|
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
|
|
/// producer of that order (predicate <c>ctx.GetTag</c> reads first, then first-seen message
|
|
/// template tokens). Downstream byte-parity between the live composer and the artifact-decode
|
|
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
|
|
/// </remarks>
|
|
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
|
|
other is not null &&
|
|
ScriptedAlarmId == other.ScriptedAlarmId &&
|
|
EquipmentId == other.EquipmentId &&
|
|
Name == other.Name &&
|
|
AlarmType == other.AlarmType &&
|
|
Severity == other.Severity &&
|
|
MessageTemplate == other.MessageTemplate &&
|
|
PredicateScriptId == other.PredicateScriptId &&
|
|
PredicateSource == other.PredicateSource &&
|
|
HistorizeToAveva == other.HistorizeToAveva &&
|
|
Retain == other.Retain &&
|
|
Enabled == other.Enabled &&
|
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = new HashCode();
|
|
hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name);
|
|
hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate);
|
|
hash.Add(PredicateScriptId); hash.Add(PredicateSource);
|
|
hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled);
|
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
|
return hash.ToHashCode();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
|
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
|
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
|
|
///
|
|
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
|
|
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
|
|
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
|
|
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
|
|
/// (composer → applier → sink → node manager) chain.
|
|
/// </summary>
|
|
public static class Phase7Composer
|
|
{
|
|
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <returns>The composition result.</returns>
|
|
public static Phase7CompositionResult Compose(
|
|
IReadOnlyList<Equipment> equipment,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
|
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
|
|
|
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
|
|
/// <param name="unsAreas">The UNS areas.</param>
|
|
/// <param name="unsLines">The UNS lines.</param>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <returns>The composition result.</returns>
|
|
public static Phase7CompositionResult Compose(
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
|
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
|
|
|
/// <summary>
|
|
/// Composes the address space build plan from the configuration entities.
|
|
/// </summary>
|
|
/// <param name="unsAreas">The UNS areas.</param>
|
|
/// <param name="unsLines">The UNS lines.</param>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <param name="tags">The tags.</param>
|
|
/// <param name="namespaces">The namespaces.</param>
|
|
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
|
|
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
|
/// <returns>The composition result.</returns>
|
|
public static Phase7CompositionResult Compose(
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
|
IReadOnlyList<Tag> tags,
|
|
IReadOnlyList<Namespace> namespaces,
|
|
IReadOnlyList<VirtualTag>? virtualTags = null,
|
|
IReadOnlyList<Script>? scripts = null)
|
|
{
|
|
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
|
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
|
var areas = unsAreas
|
|
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
|
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
|
.ToList();
|
|
|
|
var lines = unsLines
|
|
.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
|
|
.Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
|
|
.ToList();
|
|
|
|
var nodes = equipment
|
|
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
|
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
|
|
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
|
|
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
|
|
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
|
|
.ToList();
|
|
|
|
var plans = driverInstances
|
|
.OrderBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.Select(d => new DriverInstancePlan(d.DriverInstanceId, d.DriverType, d.DriverConfig))
|
|
.ToList();
|
|
|
|
var alarms = scriptedAlarms
|
|
.OrderBy(a => a.ScriptedAlarmId, StringComparer.Ordinal)
|
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
|
.ToList();
|
|
|
|
// SystemPlatform tags = Galaxy tags. Match each tag to its DriverInstance and that
|
|
// driver's Namespace; emit only when the namespace kind is SystemPlatform AND the tag
|
|
// has no EquipmentId (per the entity invariant for SystemPlatform).
|
|
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
|
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
|
|
|
var galaxyTags = tags
|
|
.Where(t => t.EquipmentId is null)
|
|
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
|
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
|
&& ns.Kind == NamespaceKind.SystemPlatform)
|
|
.OrderBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
|
.ThenBy(t => t.FolderPath, StringComparer.Ordinal)
|
|
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
|
.Select(t => new GalaxyTagPlan(
|
|
TagId: t.TagId,
|
|
DriverInstanceId: t.DriverInstanceId,
|
|
FolderPath: t.FolderPath ?? string.Empty,
|
|
DisplayName: t.Name,
|
|
DataType: t.DataType,
|
|
// MXAccess reference: "FolderPath.Name" when FolderPath is set, else just "Name".
|
|
MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"))
|
|
.ToList();
|
|
|
|
// Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId)
|
|
// whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference
|
|
// pulled from TagConfig — it becomes the variable's NodeId + read/write routing key.
|
|
var equipmentTags = tags
|
|
.Where(t => t.EquipmentId is not null)
|
|
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
|
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
|
&& ns.Kind == NamespaceKind.Equipment)
|
|
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
|
|
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
|
.Select(t => new EquipmentTagPlan(
|
|
TagId: t.TagId,
|
|
EquipmentId: t.EquipmentId!,
|
|
DriverInstanceId: t.DriverInstanceId,
|
|
FolderPath: t.FolderPath ?? string.Empty,
|
|
Name: t.Name,
|
|
DataType: t.DataType,
|
|
FullName: ExtractTagFullName(t.TagConfig)))
|
|
.ToList();
|
|
|
|
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
|
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
|
|
// scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused
|
|
// across N identical machines resolves to N machine-specific dependency graphs.
|
|
var baseByEquip = equipmentTags
|
|
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
|
StringComparer.Ordinal);
|
|
|
|
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
|
// expression source. The {{equip}} token is substituted with the owning equipment's tag
|
|
// base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
|
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
|
// VirtualTag has no FolderPath today → "".
|
|
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
|
var equipmentVirtualTags = vtags
|
|
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(v => v.Name, StringComparer.Ordinal)
|
|
.Select(v =>
|
|
{
|
|
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
|
|
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
|
src, baseByEquip.GetValueOrDefault(v.EquipmentId));
|
|
return new EquipmentVirtualTagPlan(
|
|
VirtualTagId: v.VirtualTagId,
|
|
EquipmentId: v.EquipmentId,
|
|
FolderPath: string.Empty,
|
|
Name: v.Name,
|
|
DataType: v.DataType,
|
|
Expression: expanded,
|
|
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded));
|
|
})
|
|
.ToList();
|
|
|
|
// Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by
|
|
// PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script
|
|
// row is SKIPPED (not emitted) with a structured warning rather than failing the whole
|
|
// compose — the upstream draft validator is the authority that should prevent the dangling
|
|
// reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the
|
|
// distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen
|
|
// template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried
|
|
// (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by
|
|
// EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable.
|
|
//
|
|
// Eager foreach (not lazy LINQ Select) so the Trace.TraceWarning fires exactly once per
|
|
// compose call; a lazy select would re-fire on every re-enumeration of the LINQ chain.
|
|
var equipmentScriptedAlarms = new List<EquipmentScriptedAlarmPlan>();
|
|
foreach (var a in scriptedAlarms
|
|
.OrderBy(a => a.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal))
|
|
{
|
|
if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s))
|
|
{
|
|
Trace.TraceWarning(
|
|
"Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " +
|
|
"script '{2}' which is not in the supplied scripts — skipping.",
|
|
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
|
continue;
|
|
}
|
|
var source = s.SourceCode;
|
|
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
|
|
ScriptedAlarmId: a.ScriptedAlarmId,
|
|
EquipmentId: a.EquipmentId,
|
|
Name: a.Name,
|
|
AlarmType: a.AlarmType,
|
|
Severity: a.Severity,
|
|
MessageTemplate: a.MessageTemplate,
|
|
PredicateScriptId: a.PredicateScriptId,
|
|
PredicateSource: source,
|
|
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
|
|
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
|
|
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
|
|
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
|
HistorizeToAveva: a.HistorizeToAveva,
|
|
Retain: a.Retain,
|
|
Enabled: a.Enabled));
|
|
}
|
|
|
|
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
|
|
{
|
|
EquipmentTags = equipmentTags,
|
|
EquipmentVirtualTags = equipmentVirtualTags,
|
|
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
|
|
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
|
|
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
|
|
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
|
|
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
|
|
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
|
|
/// </summary>
|
|
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
|
|
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
|
|
private static string ExtractTagFullName(string tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(tagConfig);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
|
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
|
&& fullName.ValueKind == JsonValueKind.String)
|
|
{
|
|
return fullName.GetString() ?? tagConfig;
|
|
}
|
|
}
|
|
catch (JsonException) { /* fall through to raw blob */ }
|
|
return tagConfig;
|
|
}
|
|
}
|