Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a0596fb1 | |||
| 219d10a22d | |||
| 607dc51dec |
@@ -27,5 +27,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||||
|
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||||
|
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||||
|
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
|
||||||
|
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
|
||||||
|
/// root. Idempotent: calling twice with the same id is safe.
|
||||||
|
/// </summary>
|
||||||
|
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||||
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
||||||
@@ -33,5 +41,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
private NullOpcUaAddressSpaceSink() { }
|
private NullOpcUaAddressSpaceSink() { }
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using SerilogLogger = Serilog.ILogger;
|
||||||
|
using SerilogLog = Serilog.Log;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
|
||||||
|
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
|
||||||
|
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
|
||||||
|
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
|
||||||
|
/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
|
||||||
|
///
|
||||||
|
/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
|
||||||
|
/// as <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
|
||||||
|
/// preserves the prior state on failure (does not flip Active/Inactive).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
||||||
|
private readonly TimeSpan _runTimeout;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||||
|
{
|
||||||
|
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
|
||||||
|
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
|
||||||
|
|
||||||
|
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.Compile);
|
||||||
|
}
|
||||||
|
catch (CompilationErrorException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ScriptSandboxViolationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCache = BuildReadCache(dependencies);
|
||||||
|
var context = new AlarmPredicateContext(readCache, ScriptLogger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(_runTimeout);
|
||||||
|
var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||||
|
return ScriptedAlarmEvalResult.Ok(active);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||||
|
IReadOnlyDictionary<string, object?> deps)
|
||||||
|
{
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var kv in deps)
|
||||||
|
{
|
||||||
|
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var ev in _cache.Values)
|
||||||
|
{
|
||||||
|
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
using SerilogLogger = Serilog.ILogger;
|
||||||
|
using SerilogLog = Serilog.Log;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F8b — production <see cref="IVirtualTagEvaluator"/> binding. Compiles each unique
|
||||||
|
/// expression once via <see cref="ScriptEvaluator{TContext, TResult}"/> (Roslyn-backed
|
||||||
|
/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are
|
||||||
|
/// in-process method invocations on the dependency dictionary — fast enough to run inline
|
||||||
|
/// inside the actor's message handler.
|
||||||
|
///
|
||||||
|
/// Single-tag mode: cross-tag <c>ctx.SetVirtualTag</c> writes are dropped (logged) because
|
||||||
|
/// fan-out between actors is owned by <c>DependencyMuxActor</c>, not by the eval engine.
|
||||||
|
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
|
||||||
|
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
||||||
|
private readonly TimeSpan _runTimeout;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||||
|
{
|
||||||
|
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||||
|
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
|
||||||
|
|
||||||
|
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = _cache.GetOrAdd(expression, ScriptEvaluator<VirtualTagContext, object?>.Compile);
|
||||||
|
}
|
||||||
|
catch (CompilationErrorException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ScriptSandboxViolationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCache = BuildReadCache(dependencies);
|
||||||
|
var context = new VirtualTagContext(
|
||||||
|
readCache,
|
||||||
|
setVirtualTag: (path, _) =>
|
||||||
|
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
||||||
|
virtualTagId, path),
|
||||||
|
logger: ScriptLogger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(_runTimeout);
|
||||||
|
var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||||
|
return VirtualTagEvalResult.Ok(raw);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||||
|
IReadOnlyDictionary<string, object?> deps)
|
||||||
|
{
|
||||||
|
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
|
||||||
|
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
|
||||||
|
// values stay null; the script can null-check via GetTag(path).Value.
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var kv in deps)
|
||||||
|
{
|
||||||
|
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var ev in _cache.Values)
|
||||||
|
{
|
||||||
|
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ using ZB.MOM.WW.OtOpcUa.Cluster;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host;
|
using ZB.MOM.WW.OtOpcUa.Host;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
@@ -70,6 +72,19 @@ if (hasDriver)
|
|||||||
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
|
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
|
||||||
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
|
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
|
||||||
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
|
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
|
||||||
|
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
|
||||||
|
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||||
|
// scripts at runtime.
|
||||||
|
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||||
|
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
||||||
|
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||||
|
|
||||||
|
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
||||||
|
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
||||||
|
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
||||||
|
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||||
|
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||||
|
|
||||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<AssemblyName>OtOpcUa.Host</AssemblyName>
|
<AssemblyName>OtOpcUa.Host</AssemblyName>
|
||||||
<UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId>
|
<UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<!-- Microsoft.CodeAnalysis.CSharp.Scripting (4.12.0, pulled in via Core.Scripting for F8b
|
||||||
|
user-script compilation) requires CodeAnalysis.Common 4.12.0 exactly, but ASP.NET
|
||||||
|
Core's transitive Microsoft.CodeAnalysis.CSharp 5.0.0 wins resolution. Suppress
|
||||||
|
NU1608 — the surface we use from Scripting (ScriptEvaluator + RoslynScriptHost) is
|
||||||
|
stable across the version drift; verified by Core.Scripting.Tests. -->
|
||||||
|
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -24,6 +30,9 @@
|
|||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
||||||
private FolderState? _root;
|
private FolderState? _root;
|
||||||
|
|
||||||
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
||||||
@@ -36,6 +37,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int VariableCount => _variables.Count;
|
public int VariableCount => _variables.Count;
|
||||||
|
public int FolderCount => _folders.Count;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||||
@@ -73,9 +75,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clear every registered variable from the address space. Phase7Applier calls this
|
/// <summary>
|
||||||
/// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the
|
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
||||||
/// next pass.</summary>
|
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
||||||
|
/// #85 — used by <see cref="Phase7Applier"/> to materialise the UNS Area/Line/Equipment
|
||||||
|
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
|
||||||
|
/// folder so adding child variables under it still works.
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||||
|
|
||||||
|
if (_folders.ContainsKey(folderNodeId)) return;
|
||||||
|
|
||||||
|
lock (Lock)
|
||||||
|
{
|
||||||
|
if (_folders.ContainsKey(folderNodeId)) return;
|
||||||
|
|
||||||
|
var parent = ResolveParentFolder(parentNodeId);
|
||||||
|
var folder = new FolderState(parent)
|
||||||
|
{
|
||||||
|
NodeId = new NodeId(folderNodeId, NamespaceIndex),
|
||||||
|
BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
|
||||||
|
DisplayName = displayName,
|
||||||
|
EventNotifier = EventNotifiers.None,
|
||||||
|
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||||
|
};
|
||||||
|
parent.AddChild(folder);
|
||||||
|
AddPredefinedNode(SystemContext, folder);
|
||||||
|
_folders[folderNodeId] = folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
||||||
|
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||||||
|
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||||||
public void RebuildAddressSpace()
|
public void RebuildAddressSpace()
|
||||||
{
|
{
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
@@ -86,9 +122,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
PredefinedNodes?.Remove(v.NodeId);
|
PredefinedNodes?.Remove(v.NodeId);
|
||||||
}
|
}
|
||||||
_variables.Clear();
|
_variables.Clear();
|
||||||
|
|
||||||
|
foreach (var f in _folders.Values)
|
||||||
|
{
|
||||||
|
f.Parent?.RemoveChild(f);
|
||||||
|
PredefinedNodes?.Remove(f.NodeId);
|
||||||
|
}
|
||||||
|
_folders.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private FolderState ResolveParentFolder(string? parentNodeId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
|
||||||
|
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,43 @@ public sealed class Phase7Applier
|
|||||||
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
|
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
|
||||||
|
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
|
||||||
|
/// clients browsing the server see proper folder structure instead of flat tag ids.
|
||||||
|
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
|
||||||
|
/// present, so re-applies are cheap.
|
||||||
|
/// </summary>
|
||||||
|
public void MaterialiseHierarchy(Phase7CompositionResult composition)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(composition);
|
||||||
|
|
||||||
|
foreach (var area in composition.UnsAreas)
|
||||||
|
{
|
||||||
|
SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
|
||||||
|
}
|
||||||
|
foreach (var line in composition.UnsLines)
|
||||||
|
{
|
||||||
|
SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
|
||||||
|
}
|
||||||
|
foreach (var equipment in composition.EquipmentNodes)
|
||||||
|
{
|
||||||
|
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
||||||
|
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
|
||||||
|
SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
|
||||||
|
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||||
|
{
|
||||||
|
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
||||||
|
}
|
||||||
|
|
||||||
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
{
|
{
|
||||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||||
|
|||||||
@@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.</summary>
|
/// <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(
|
public sealed record Phase7CompositionResult(
|
||||||
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||||
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans);
|
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
||||||
|
{
|
||||||
|
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
|
||||||
|
public Phase7CompositionResult(
|
||||||
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||||
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||||
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||||
|
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
||||||
|
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
|
||||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
||||||
@@ -17,18 +35,38 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
|||||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
/// 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.
|
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
|
||||||
///
|
///
|
||||||
/// Full migration of the legacy <c>Server.Phase7.Phase7Composer</c> (which mutates a server-side
|
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
|
||||||
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) is tracked as
|
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
|
||||||
/// follow-up F14. This pure version handles the projection step; the side-effecting wiring
|
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
|
||||||
/// stays in the legacy code until F14 lands.
|
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
|
||||||
|
/// (composer → applier → sink → node manager) chain.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Phase7Composer
|
public static class Phase7Composer
|
||||||
{
|
{
|
||||||
|
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
|
||||||
public static Phase7CompositionResult Compose(
|
public static Phase7CompositionResult Compose(
|
||||||
|
IReadOnlyList<Equipment> equipment,
|
||||||
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||||
|
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
||||||
|
|
||||||
|
public static Phase7CompositionResult Compose(
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
||||||
{
|
{
|
||||||
|
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
|
var nodes = equipment
|
||||||
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
||||||
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
|
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
|
||||||
@@ -44,6 +82,6 @@ public static class Phase7Composer
|
|||||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new Phase7CompositionResult(nodes, plans, alarms);
|
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||||
|
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||||
|
|
||||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,34 +89,34 @@ public static class DeploymentArtifact
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
||||||
{
|
{
|
||||||
if (blob.IsEmpty)
|
if (blob.IsEmpty) return Empty();
|
||||||
{
|
|
||||||
return new Phase7CompositionResult(
|
|
||||||
Array.Empty<EquipmentNode>(),
|
|
||||||
Array.Empty<DriverInstancePlan>(),
|
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||||
|
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||||
|
|
||||||
return new Phase7CompositionResult(equipment, drivers, alarms);
|
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return new Phase7CompositionResult(
|
return Empty();
|
||||||
Array.Empty<EquipmentNode>(),
|
|
||||||
Array.Empty<DriverInstancePlan>(),
|
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Phase7CompositionResult Empty() => new(
|
||||||
|
Array.Empty<UnsAreaProjection>(),
|
||||||
|
Array.Empty<UnsLineProjection>(),
|
||||||
|
Array.Empty<EquipmentNode>(),
|
||||||
|
Array.Empty<DriverInstancePlan>(),
|
||||||
|
Array.Empty<ScriptedAlarmPlan>());
|
||||||
|
|
||||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
@@ -137,12 +137,31 @@ public static class DeploymentArtifact
|
|||||||
|
|
||||||
private static string IdentityOf<T>(T item) where T : class => item switch
|
private static string IdentityOf<T>(T item) where T : class => item switch
|
||||||
{
|
{
|
||||||
|
UnsAreaProjection a => a.UnsAreaId,
|
||||||
|
UnsLineProjection l => l.UnsLineId,
|
||||||
EquipmentNode e => e.EquipmentId,
|
EquipmentNode e => e.EquipmentId,
|
||||||
DriverInstancePlan d => d.DriverInstanceId,
|
DriverInstancePlan d => d.DriverInstanceId,
|
||||||
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static UnsAreaProjection? ReadAreaProjection(JsonElement el)
|
||||||
|
{
|
||||||
|
var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||||
|
return new UnsAreaProjection(id!, name ?? id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnsLineProjection? ReadLineProjection(JsonElement el)
|
||||||
|
{
|
||||||
|
var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null;
|
||||||
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null;
|
||||||
|
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||||
|
}
|
||||||
|
|
||||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||||
{
|
{
|
||||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
|
|
||||||
var outcome = _applier.Apply(plan);
|
var outcome = _applier.Apply(plan);
|
||||||
_lastApplied = composition;
|
_lastApplied = composition;
|
||||||
|
|
||||||
|
// #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA
|
||||||
|
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||||
|
// skips folders that already exist with the same node id.
|
||||||
|
_applier.MaterialiseHierarchy(composition);
|
||||||
|
|
||||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||||
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
|
||||||
|
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
|
||||||
|
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
|
||||||
|
/// AlarmPredicateContext throws on writes — predicates must stay pure).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_returning_true_reports_Active()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-hi",
|
||||||
|
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||||
|
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Active.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-hi",
|
||||||
|
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||||
|
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Active.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||||
|
|
||||||
|
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||||
|
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
||||||
|
|
||||||
|
first.Active.ShouldBeTrue();
|
||||||
|
second.Active.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_compile_error_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-bad-write",
|
||||||
|
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
|
||||||
|
dependencies: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("threw");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_empty_predicate_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_after_dispose_returns_Failure()
|
||||||
|
{
|
||||||
|
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
sut.Dispose();
|
||||||
|
|
||||||
|
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("disposed");
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F8b — verifies <see cref="RoslynVirtualTagEvaluator"/> compiles user expressions through
|
||||||
|
/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
|
||||||
|
/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
|
||||||
|
/// runtime throw) as <c>VirtualTagEvalResult.Failure</c> instead of propagating exceptions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynVirtualTagEvaluatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_simple_addition_returns_summed_value()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
virtualTagId: "vt-sum",
|
||||||
|
expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
|
||||||
|
dependencies: new Dictionary<string, object?> { ["a"] = 10, ["b"] = 32 });
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Value.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_caches_compiled_expression_across_calls()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
||||||
|
|
||||||
|
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
||||||
|
var second = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });
|
||||||
|
|
||||||
|
first.Success.ShouldBeTrue(first.Reason);
|
||||||
|
first.Value.ShouldBe(10);
|
||||||
|
second.Success.ShouldBeTrue(second.Reason);
|
||||||
|
second.Value.ShouldBe(14);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_compile_error_returns_Failure_with_reason()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason.ShouldNotBeNull();
|
||||||
|
result.Reason.ShouldContain("compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
virtualTagId: "vt-div0",
|
||||||
|
expression: "int a = 0; return 1 / a;",
|
||||||
|
dependencies: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason.ShouldNotBeNull();
|
||||||
|
result.Reason.ShouldContain("threw");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_empty_expression_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
|
||||||
|
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
|
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_after_dispose_returns_Failure()
|
||||||
|
{
|
||||||
|
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||||
|
sut.Dispose();
|
||||||
|
|
||||||
|
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("disposed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua.Server;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #85 — verifies <see cref="Phase7Applier.MaterialiseHierarchy"/> builds the UNS
|
||||||
|
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
|
||||||
|
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
|
||||||
|
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
|
||||||
|
/// count to prove the folders land in the SDK address space.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Phase7ApplierHierarchyTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private readonly string _pkiRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"otopcua-pki-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
|
||||||
|
{
|
||||||
|
var sink = new RecordingFolderSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
|
||||||
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
||||||
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||||
|
|
||||||
|
applier.MaterialiseHierarchy(composition);
|
||||||
|
|
||||||
|
var calls = sink.Calls;
|
||||||
|
calls.Count.ShouldBe(3);
|
||||||
|
calls[0].ShouldBe(("area-1", null, "Plant North"));
|
||||||
|
calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
|
||||||
|
calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
|
||||||
|
{
|
||||||
|
var sink = new RecordingFolderSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||||
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||||
|
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||||
|
|
||||||
|
applier.MaterialiseHierarchy(composition);
|
||||||
|
|
||||||
|
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
|
||||||
|
{
|
||||||
|
await using var host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.Hierarchy",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = AllocateFreePort(),
|
||||||
|
PublicHostname = "localhost",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
},
|
||||||
|
NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
|
||||||
|
var sdkServer = new OtOpcUaSdkServer();
|
||||||
|
await host.StartAsync(sdkServer, Ct);
|
||||||
|
sdkServer.NodeManager.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
applier.MaterialiseHierarchy(new Phase7CompositionResult(
|
||||||
|
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
|
||||||
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
||||||
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
||||||
|
|
||||||
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
|
||||||
|
|
||||||
|
// Idempotent: re-applying with the same composition doesn't create duplicates.
|
||||||
|
applier.MaterialiseHierarchy(new Phase7CompositionResult(
|
||||||
|
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
|
||||||
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
||||||
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
||||||
|
|
||||||
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
|
||||||
|
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
|
||||||
|
|
||||||
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
public void RebuildAddressSpace() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,13 +125,17 @@ public sealed class Phase7ApplierTests
|
|||||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
||||||
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
||||||
public int RebuildCalls;
|
public int RebuildCalls;
|
||||||
|
|
||||||
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
||||||
|
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +149,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||||
}
|
}
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -172,6 +172,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
public int Writes { get; private set; }
|
public int Writes { get; private set; }
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
=> Calls.Enqueue($"WV:{nodeId}");
|
=> Calls.Enqueue($"WV:{nodeId}");
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> Calls.Enqueue($"EF:{folderNodeId}");
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
||||||
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
||||||
|
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user