530 lines
30 KiB
C#
530 lines
30 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.</summary>
|
|
public sealed record Phase7CompositionResult(
|
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
|
{
|
|
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS 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)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
|
|
/// in an <c>Equipment</c>-kind namespace. <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 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 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. <see cref="Writable"/>
|
|
/// mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
|
|
/// <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
|
|
/// stays read-only. This flag is derived identically on the artifact-decode side
|
|
/// (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity. <see cref="Alarm"/> carries
|
|
/// the optional native-alarm intent parsed from <c>Tag.TagConfig</c>'s <c>alarm</c> object (null ⇒
|
|
/// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity.
|
|
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> carry the optional server-side
|
|
/// HistoryRead intent parsed from <c>Tag.TagConfig</c>'s <c>isHistorized</c> bool +
|
|
/// <c>historianTagname</c> string (Phase C); a null <see cref="HistorianTagname"/> means the historian
|
|
/// tagname defaults to <see cref="FullName"/> (resolved later, not here). Both are parsed identically
|
|
/// on the artifact-decode side for byte-parity.
|
|
/// </summary>
|
|
public sealed record EquipmentTagPlan(
|
|
string TagId,
|
|
string EquipmentId,
|
|
string DriverInstanceId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string FullName,
|
|
bool Writable,
|
|
EquipmentTagAlarmInfo? Alarm,
|
|
bool IsHistorized = false,
|
|
string? HistorianTagname = null);
|
|
|
|
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
|
|
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
|
|
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.</summary>
|
|
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
|
|
|
|
/// <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>
|
|
/// <param name="Historize">When true, this VirtualTag's values are historized (carried from the
|
|
/// <c>VirtualTag.Historize</c> entity column). Threaded through the deploy-diff equality below so a
|
|
/// Historize-only toggle is detected as a change. Defaults to <c>false</c> — matching both the CLR
|
|
/// default of the <c>bool VirtualTag.Historize</c> column and the artifact-decode default when the flag is absent/non-bool —
|
|
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
|
|
public sealed record EquipmentVirtualTagPlan(
|
|
string VirtualTagId,
|
|
string EquipmentId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string Expression,
|
|
IReadOnlyList<string> DependencyRefs,
|
|
bool Historize = false)
|
|
{
|
|
/// <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. <see cref="Historize"/> is included so a Historize-only
|
|
/// toggle is detected as a change.</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 &&
|
|
Historize == other.Historize &&
|
|
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);
|
|
hash.Add(Historize);
|
|
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 supply UNS topology or tags.</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 supply tags.</summary>
|
|
/// <param name="unsAreas">The UNS areas.</param>
|
|
/// <param name="unsLines">The UNS lines.</param>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <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();
|
|
|
|
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
|
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
|
|
|
// Equipment tags = a Tag bound to an Equipment (non-null EquipmentId) whose driver's namespace
|
|
// is Equipment-kind. FullName is the driver-side wire reference pulled from TagConfig — it
|
|
// becomes the variable's NodeId + read/write routing key. Galaxy points are ordinary equipment
|
|
// tags now (GalaxyMxGateway is a standard Equipment-kind driver), so no driver-type exception.
|
|
var equipmentTags = tags
|
|
.Where(t => t.EquipmentId is not null)
|
|
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
|
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
|
&& ns.Kind == NamespaceKind.Equipment)
|
|
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
|
|
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
|
.Select(t =>
|
|
{
|
|
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
|
|
return new EquipmentTagPlan(
|
|
TagId: t.TagId,
|
|
EquipmentId: t.EquipmentId!,
|
|
DriverInstanceId: t.DriverInstanceId,
|
|
FolderPath: t.FolderPath ?? string.Empty,
|
|
Name: t.Name,
|
|
DataType: t.DataType,
|
|
FullName: ExtractTagFullName(t.TagConfig),
|
|
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
|
Alarm: ExtractTagAlarm(t.TagConfig),
|
|
IsHistorized: isHistorized,
|
|
HistorianTagname: historianTagname);
|
|
})
|
|
.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),
|
|
Historize: v.Historize);
|
|
})
|
|
.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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
|
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
|
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
|
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(tagConfig);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
|
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
|
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
|
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
|
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
|
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
|
return new EquipmentTagAlarmInfo(type, sev);
|
|
}
|
|
catch (JsonException) { return null; }
|
|
}
|
|
|
|
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
|
|
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
|
|
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
|
|
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
|
|
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
|
|
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
|
|
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
|
|
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(tagConfig);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
|
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
|
|
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
|
&& hEl.GetBoolean();
|
|
string? tagname = null;
|
|
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
|
|
&& nEl.ValueKind == JsonValueKind.String)
|
|
{
|
|
var raw = nEl.GetString();
|
|
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
|
|
}
|
|
return (isHistorized, tagname);
|
|
}
|
|
catch (JsonException) { return (false, null); }
|
|
}
|
|
}
|