task-galaxy-e2e branch — non-FOCAS work-in-progress snapshot
Catch-all commit for pending work on the task-galaxy-e2e branch that
wasn't part of the FOCAS migration. Grouping by topic so future per-topic
commits can be cherry-picked if needed.
TwinCAT
- src/.../Driver.TwinCAT/AdsTwinCATClient.cs + TwinCATDriverFactoryExtensions.cs:
factory-registration extensions + ADS client refinements.
- src/.../Driver.TwinCAT.Cli/Commands/BrowseCommand.cs: new browse command
for the TwinCAT test-client CLI.
- tests/.../Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs + TwinCatProject/:
fixture scaffold with a minimal POU + README pointing at the TCBSD/ESXi
VM for e2e.
- docs/Driver.TwinCAT.Cli.md + docs/drivers/TwinCAT-Test-Fixture.md:
documentation for the above.
- docs/v3/twincat-backlog.md: forward-looking backlog seed.
Admin UI + fleet status
- src/.../Admin/Components/Pages/Clusters/DriversTab.razor + Hosts.razor:
UI refresh for fleet-status rendering.
- src/.../Admin/Hubs/FleetStatusHub.cs + FleetStatusPoller.cs +
Admin/Program.cs: SignalR hub + poller plumbing for live fleet data.
- tests/.../Admin.Tests/FleetStatusPollerTests.cs: poller coverage.
Server + redundancy runtime (Phase 6.3 follow-ups)
- src/.../Server/Hosting/RedundancyPublisherHostedService.cs: HostedService
that owns the RedundancyStatePublisher lifecycle + wires peer reachability.
- src/.../Server/Redundancy/ServerRedundancyNodeWriter.cs: OPC UA
variable-node writer binding ServiceLevel + ServerUriArray to the
publisher's events.
- src/.../Server/Program.cs + Server.csproj: hosted-service registration.
- tests/.../Server.Tests/ServerRedundancyNodeWriterTests.cs +
Server.Tests.csproj: coverage for the above.
Configuration
- src/.../Configuration/Validation/DraftValidator.cs +
tests/.../Configuration.Tests/DraftValidatorTests.cs: draft-validation
refinements.
E2E scripts (shared infrastructure)
- scripts/e2e/README.md + _common.ps1 + test-all.ps1: shared helpers + the
all-drivers test-all runner.
- scripts/e2e/test-opcuaclient.ps1: OPC UA Client e2e runner.
Docs
- docs/v2/implementation/phase-6-{1,2,3,4}*.md + exit-gate-phase-{3,7}.md:
phase-gate + implementation doc updates.
- docs/v2/plan.md: top-level plan refresh.
- docs/v2/redundancy-interop-playbook.md: client interop playbook for the
Phase 6.3 redundancy-runtime work.
Two orphan FOCAS docs remain on disk but deliberately unstaged —
docs/v2/focas-deployment.md and docs/v2/implementation/focas-simulator-plan.md
describe the now-retired Tier-C topology and should either be rewritten
or deleted in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,21 @@ else
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr><td><code>@d.DriverInstanceId</code></td><td>@d.Name</td><td>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||
<tr>
|
||||
<td><code>@d.DriverInstanceId</code></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><code>@d.NamespaceId</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host status</h1>
|
||||
|
||||
@@ -128,6 +131,7 @@ else
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -136,6 +140,44 @@ else
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
// Phase 6.1 Stream E.2 — subscribe to FleetStatusHub so resilience deltas upsert the
|
||||
// matching row without waiting for the next RefreshIntervalSeconds tick. The 10 s
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet");
|
||||
_hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build();
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; polling refresh is the fallback. Swallow connect errors
|
||||
// so the page still renders against the initial RefreshAsync pass.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(ResilienceStatusChangedMessage msg)
|
||||
{
|
||||
if (_rows is null) return;
|
||||
var idx = _rows.FindIndex(r =>
|
||||
r.DriverInstanceId == msg.DriverInstanceId && r.HostName == msg.HostName);
|
||||
if (idx < 0) return;
|
||||
var prior = _rows[idx];
|
||||
_rows[idx] = prior with
|
||||
{
|
||||
ConsecutiveFailures = msg.ConsecutiveFailures,
|
||||
LastCircuitBreakerOpenUtc = msg.LastCircuitBreakerOpenUtc,
|
||||
CurrentBulkheadDepth = msg.CurrentBulkheadDepth,
|
||||
LastRecycleUtc = msg.LastRecycleUtc,
|
||||
};
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
@@ -180,5 +222,12 @@ else
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
if (_hub is not null)
|
||||
{
|
||||
try { await _hub.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,18 @@ public sealed record NodeStateChangedMessage(
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <c>FleetStatusPoller</c> when it observes a change in a
|
||||
/// <c>DriverInstanceResilienceStatus</c> row. Closes the last Phase 6.1 Stream E.2/E.3
|
||||
/// deferral — lets the Admin <c>/hosts</c> page upsert the matching row without the
|
||||
/// 10-second polling round-trip. Keyed on (DriverInstanceId, HostName); the client
|
||||
/// fan-outs to the matching row by matching both.
|
||||
/// </summary>
|
||||
public sealed record ResilienceStatusChangedMessage(
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
Binary file not shown.
@@ -44,6 +44,7 @@ builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<FocasDriverDetailService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
@@ -173,4 +174,65 @@ public static class DraftValidator
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
|
||||
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
|
||||
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
|
||||
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
|
||||
/// <see cref="ClusterNode.Enabled"/> flag on child nodes — an operator can toggle
|
||||
/// nodes off (effective count = 1) while leaving RedundancyMode at Hot and the
|
||||
/// constraint stays green. This check catches that drift before publish so the
|
||||
/// runtime doesn't boot into a topology the <see cref="Enums.RedundancyMode"/> claims
|
||||
/// is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called from the publish pipeline separately from <see cref="Validate"/> because the
|
||||
/// cluster/nodes rows aren't generation-versioned — they don't belong on
|
||||
/// <see cref="DraftSnapshot"/>. Returns every failing rule in one pass, same shape as
|
||||
/// <see cref="Validate"/>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
ArgumentNullException.ThrowIfNull(clusterNodes);
|
||||
|
||||
var errors = new List<ValidationError>();
|
||||
var enabledNodes = clusterNodes.Count(n => n.Enabled);
|
||||
|
||||
// Declared count must match declared mode (belt around the SQL CHECK).
|
||||
var declaredOk = (cluster.NodeCount, cluster.RedundancyMode) switch
|
||||
{
|
||||
(1, RedundancyMode.None) => true,
|
||||
(2, RedundancyMode.Warm) => true,
|
||||
(2, RedundancyMode.Hot) => true,
|
||||
_ => false,
|
||||
};
|
||||
if (!declaredOk)
|
||||
errors.Add(new("ClusterRedundancyModeInvalid",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} + RedundancyMode={cluster.RedundancyMode}. " +
|
||||
$"Supported combinations: (1, None), (2, Warm), (2, Hot).",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Enabled-node count must match declared count. Disabling a node to 1 while leaving
|
||||
// mode at Hot/Warm would boot the runtime into InvalidTopology band.
|
||||
if (enabledNodes != cluster.NodeCount)
|
||||
errors.Add(new("ClusterEnabledNodeCountMismatch",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} but has {enabledNodes} Enabled nodes. " +
|
||||
$"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation
|
||||
// regardless of mode; catch it here so publish fails loud rather than the runtime
|
||||
// demoting both to ServiceLevelBand.InvalidTopology at boot.
|
||||
var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary);
|
||||
if (primaryCount > 1)
|
||||
errors.Add(new("ClusterMultiplePrimary",
|
||||
$"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.",
|
||||
cluster.ClusterId));
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table (ADS <c>SymbolLoaderFactory</c>, flat mode) and print every
|
||||
/// symbol the driver's atomic-type mapper recognizes. Same path <c>DiscoverAsync</c> takes
|
||||
/// when <c>EnableControllerBrowse = true</c> — structured UDTs / function-block instances
|
||||
/// won't appear because the driver filters to the supported primitive surface.
|
||||
/// </summary>
|
||||
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
|
||||
public sealed class BrowseCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("prefix", Description =
|
||||
"Case-sensitive instance-path prefix to filter on (e.g. 'GVL_Fixture' or " +
|
||||
"'MAIN.'). Empty (default) prints everything.")]
|
||||
public string? Prefix { get; init; }
|
||||
|
||||
[CommandOption("max", Description =
|
||||
"Maximum number of symbols to print. 0 = unbounded (default 500 for large " +
|
||||
"controllers — flat-mode symbol counts easily top 10k).")]
|
||||
public int Max { get; init; } = 500;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// Browse-only — no declared tags. EnableControllerBrowse=true flips DiscoverAsync's
|
||||
// symbol-walk on so every recognized primitive surfaces through the builder.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = [],
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = !PollOnly,
|
||||
EnableControllerBrowse = true,
|
||||
};
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
var builder = new CollectingAddressSpaceBuilder();
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await driver.DiscoverAsync(builder, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var matched = builder.Variables
|
||||
.Where(v => string.IsNullOrEmpty(Prefix) || v.BrowseName.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
var printLimit = Max <= 0 ? matched.Count : Math.Min(Max, matched.Count);
|
||||
|
||||
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Symbols: {matched.Count} matched ({builder.Variables.Count} total), showing {printLimit}");
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
foreach (var v in matched.Take(printLimit))
|
||||
{
|
||||
var access = v.Info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
|
||||
await console.Output.WriteLineAsync($" [{access}] {v.Info.DriverDataType,-8} {v.BrowseName}");
|
||||
}
|
||||
|
||||
if (matched.Count > printLimit)
|
||||
await console.Output.WriteLineAsync(
|
||||
$" … {matched.Count - printLimit} more — raise --max or tighten --prefix");
|
||||
}
|
||||
|
||||
private sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add((browseName, info));
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,22 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
try
|
||||
{
|
||||
// Bit-indexed BOOL — TwinCAT's symbol table doesn't expose "WordVar.N" as its
|
||||
// own symbolic entry (ADS returns DeviceSymbolNotFound), so we read the parent
|
||||
// container as its widest unsigned primitive and extract the bit locally. The
|
||||
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
|
||||
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
|
||||
// rare in real code and promoting to uint is harmless for them.
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
var parent = StripBitSuffix(symbolPath);
|
||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)parentResult.ErrorCode));
|
||||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -55,11 +71,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
return (result.Value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
@@ -67,6 +79,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
|
||||
? symbolPath[..lastDot]
|
||||
: symbolPath;
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
@@ -115,12 +136,13 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
// NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange
|
||||
// fires when the value differs; OnCycle fires every cycle. OnChange is the right default
|
||||
// for OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// NotificationSettings takes cycle + max-delay in milliseconds (Beckhoff InfoSys
|
||||
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
|
||||
// the value differs; OnCycle fires every cycle. OnChange is the right default for
|
||||
// OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// changed" so we let it decide.
|
||||
var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0);
|
||||
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, 0);
|
||||
|
||||
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
@@ -172,27 +194,36 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
if (typeName is null) return null;
|
||||
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
|
||||
// the type name — strip the "(...)" suffix so sized strings map onto the bare
|
||||
// String/WString atom the driver speaks.
|
||||
var paren = typeName.IndexOf('(');
|
||||
var bare = paren > 0 ? typeName[..paren] : typeName;
|
||||
return bare switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSymbolWritable(ISymbol symbol)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="TwinCATDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper materialises TwinCAT
|
||||
/// DriverInstance rows from the central config DB into live driver instances. Mirrors
|
||||
/// <c>S7DriverFactoryExtensions</c> / <c>AbCipDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class TwinCATDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "TwinCAT";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"TwinCAT config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
};
|
||||
|
||||
return new TwinCATDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"TwinCAT config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
SymbolPath: t.SymbolPath ?? throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing SymbolPath"),
|
||||
DataType: ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field)
|
||||
where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class TwinCATDriverConfigDto
|
||||
{
|
||||
public int? TimeoutMs { get; init; }
|
||||
public bool? UseNativeNotifications { get; init; }
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
public List<TwinCATDeviceDto>? Devices { get; init; }
|
||||
public List<TwinCATTagDto>? Tags { get; init; }
|
||||
public TwinCATProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATTagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
public string? SymbolPath { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream C (task #147) glue — drives <see cref="RedundancyStatePublisher"/> on
|
||||
/// a periodic tick and pushes the resulting ServiceLevel / ServerUriArray /
|
||||
/// RedundancySupport values onto the OPC UA Server node via
|
||||
/// <see cref="ServerRedundancyNodeWriter"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA <c>ServerObject</c> exists only after <c>StandardServer.OnServerStarted</c>
|
||||
/// has run, which is inside <see cref="OpcUaApplicationHost.StartAsync"/>. This hosted
|
||||
/// service polls for <c>host.Server?.CurrentInstance</c> to become non-null before
|
||||
/// binding the writer — the server boot sequence doesn't expose a "ready" event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tick cadence is 1 s by default. The publisher is edge-triggered internally so a
|
||||
/// no-change tick is cheap; the writer is also idempotent so we can safely apply the
|
||||
/// same values every tick without generating spurious OPC UA notifications.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedundancyPublisherHostedService(
|
||||
OpcUaApplicationHost host,
|
||||
RedundancyStatePublisher publisher,
|
||||
RedundancyCoordinator coordinator,
|
||||
ILogger<RedundancyPublisherHostedService> logger,
|
||||
ILoggerFactory loggerFactory) : BackgroundService
|
||||
{
|
||||
public TimeSpan TickInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan ServerReadyPollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 0. Load topology from the shared config DB. RefreshAsync (not InitializeAsync)
|
||||
// so an invariant violation degrades to ServiceLevelBand.InvalidTopology rather
|
||||
// than crashing the hosted service — operator visibility beats fail-fast here.
|
||||
await coordinator.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
// 1. Wait for OPC UA server's ServerObject to materialize.
|
||||
var writer = await WaitForServerReadyAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (writer is null) return; // cancelled before startup completed
|
||||
|
||||
// 2. Subscribe writer to publisher events — edge-triggered ServiceLevel +
|
||||
// ServerUriArray updates from the publisher fan out onto the Server node.
|
||||
publisher.OnStateChanged += OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged += OnServerUriArrayChanged;
|
||||
|
||||
// 3. One-time RedundancySupport from the coordinator's current topology. If the
|
||||
// topology isn't loaded yet, we'll retry on the first compute-publish tick.
|
||||
ApplyRedundancySupportIfKnown(writer);
|
||||
|
||||
logger.LogInformation(
|
||||
"RedundancyPublisherHostedService running — tick every {Tick}ms",
|
||||
TickInterval.TotalMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
publisher.ComputeAndPublish();
|
||||
ApplyRedundancySupportIfKnown(writer); // cheap + idempotent
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RedundancyStatePublisher tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(TickInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
publisher.OnStateChanged -= OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged -= OnServerUriArrayChanged;
|
||||
}
|
||||
|
||||
void OnServiceLevelChanged(ServiceLevelSnapshot snap) => writer.ApplyServiceLevel(snap.Value);
|
||||
void OnServerUriArrayChanged(IReadOnlyList<string> uris) => writer.ApplyServerUriArray(uris);
|
||||
}
|
||||
|
||||
private async Task<ServerRedundancyNodeWriter?> WaitForServerReadyAsync(CancellationToken ct)
|
||||
{
|
||||
// Bounded retry so a genuine failure to start doesn't pin the hosted service forever.
|
||||
// 60s is generous — production boot is ~2s on this box; cert PKI + certificate-creation
|
||||
// cases have been observed to take up to 15s cold.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
||||
{
|
||||
var serverInternal = host.Server?.CurrentInstance;
|
||||
if (serverInternal?.ServerObject is not null)
|
||||
{
|
||||
var writerLogger = loggerFactory.CreateLogger<ServerRedundancyNodeWriter>();
|
||||
return new ServerRedundancyNodeWriter(serverInternal, writerLogger);
|
||||
}
|
||||
|
||||
try { await Task.Delay(ServerReadyPollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
logger.LogError("OPC UA ServerObject did not materialize within 60s — Phase 6.3 Stream C wiring is inactive");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyRedundancySupportIfKnown(ServerRedundancyNodeWriter writer)
|
||||
{
|
||||
var topology = coordinator.Current;
|
||||
if (topology is null) return;
|
||||
writer.ApplyRedundancySupport(topology.Mode);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
@@ -109,6 +112,7 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
AbCipDriverFactoryExtensions.Register(registry);
|
||||
AbLegacyDriverFactoryExtensions.Register(registry);
|
||||
S7DriverFactoryExtensions.Register(registry);
|
||||
TwinCATDriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
@@ -137,8 +141,29 @@ builder.Services.AddHostedService<OpcUaServerService>();
|
||||
// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
// Additional pooled factory so Phase 6.3 RedundancyCoordinator (singleton) can create its
|
||||
// own scoped DbContext for topology loading without fighting the scoped HostStatusPublisher.
|
||||
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||
|
||||
// Phase 6.3 Stream C (task #147) — ServiceLevel + ServerUriArray + RedundancySupport node
|
||||
// wiring. Coordinator holds topology; publisher computes ServiceLevel byte + ServerUriArray;
|
||||
// hosted service ticks publisher + pushes values onto the Server object via the node writer.
|
||||
builder.Services.AddSingleton(sp => new RedundancyCoordinator(
|
||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||
sp.GetRequiredService<ILogger<RedundancyCoordinator>>(),
|
||||
options.NodeId, options.ClusterId));
|
||||
builder.Services.AddSingleton<ApplyLeaseRegistry>();
|
||||
builder.Services.AddSingleton<RecoveryStateManager>();
|
||||
builder.Services.AddSingleton<PeerReachabilityTracker>();
|
||||
builder.Services.AddSingleton(sp => new RedundancyStatePublisher(
|
||||
sp.GetRequiredService<RedundancyCoordinator>(),
|
||||
sp.GetRequiredService<ApplyLeaseRegistry>(),
|
||||
sp.GetRequiredService<RecoveryStateManager>(),
|
||||
sp.GetRequiredService<PeerReachabilityTracker>()));
|
||||
builder.Services.AddHostedService<RedundancyPublisherHostedService>();
|
||||
|
||||
// Phase 7 follow-up #246 — historian sink + engine composer. NullAlarmHistorianSink
|
||||
// is the default until the Galaxy.Host SqliteStoreAndForwardSink writer adapter
|
||||
// lands (task #248). The composer reads Script/VirtualTag/ScriptedAlarm rows on
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ConfigRedundancyMode = ZB.MOM.WW.OtOpcUa.Configuration.Enums.RedundancyMode;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream C (task #147) — the seam that carries the
|
||||
/// <see cref="RedundancyStatePublisher"/>'s computed values onto the standard OPC UA
|
||||
/// Server object nodes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Server.ServiceLevel</c> (<see cref="VariableIds.Server_ServiceLevel"/>)
|
||||
/// — Byte (0..255), Part 5 §6.3.34. Clients poll to pick the healthiest peer.</item>
|
||||
/// <item><c>Server.ServerRedundancy.RedundancySupport</c>
|
||||
/// (<see cref="VariableIds.Server_ServerRedundancy_RedundancySupport"/>)
|
||||
/// — advertises Warm / Hot / Cold / None per Part 4 §6.6.2.</item>
|
||||
/// <item><c>Server.ServerRedundancy.ServerUriArray</c>
|
||||
/// (<see cref="VariableIds.NonTransparentRedundancyType_ServerUriArray"/>
|
||||
/// when the redundancy node is upgraded to non-transparent)
|
||||
/// — ApplicationUri of every node in the pair, self first.</item>
|
||||
/// </list>
|
||||
/// The writer is constructed once during the <c>OtOpcUaServer.OnServerStarted</c> hook;
|
||||
/// callers invoke <see cref="ApplyServiceLevel"/> / <see cref="ApplyServerUriArray"/> /
|
||||
/// <see cref="ApplyRedundancySupport"/> on publisher events. Each setter updates the
|
||||
/// underlying <see cref="BaseVariableState.Value"/> then calls
|
||||
/// <see cref="NodeState.ClearChangeMasks"/> to flush the change to subscribers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The writer is defensive: if the expected node shape isn't present on this particular
|
||||
/// SDK build (e.g. <c>ServerUriArray</c> only exists on the
|
||||
/// <c>NonTransparentRedundancyType</c> subtype and the ServerObject's default
|
||||
/// <c>ServerRedundancy</c> property is the base type) the writer logs a warning once and
|
||||
/// skips that specific update rather than throwing — matches the SDK's own tolerance
|
||||
/// for optional address-space shape.
|
||||
/// </remarks>
|
||||
public sealed class ServerRedundancyNodeWriter
|
||||
{
|
||||
private readonly IServerInternal _server;
|
||||
private readonly ILogger<ServerRedundancyNodeWriter> _logger;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private bool _warnedMissingServerUriArray;
|
||||
private byte? _lastServiceLevel;
|
||||
private RedundancySupport? _lastRedundancySupport;
|
||||
private IReadOnlyList<string>? _lastServerUriArray;
|
||||
|
||||
public ServerRedundancyNodeWriter(IServerInternal server, ILogger<ServerRedundancyNodeWriter> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_server = server;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Push a new Byte value onto <c>Server.ServiceLevel</c> + notify subscribers.</summary>
|
||||
public void ApplyServiceLevel(byte value)
|
||||
{
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServiceLevel is not { } node) return;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastServiceLevel == value) return;
|
||||
_lastServiceLevel = value;
|
||||
node.Value = value;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the Configuration-side <see cref="ConfigRedundancyMode"/> to OPC UA's
|
||||
/// <see cref="RedundancySupport"/> enum + apply to
|
||||
/// <c>Server.ServerRedundancy.RedundancySupport</c>. Called once at
|
||||
/// the <c>OtOpcUaServer.OnServerStarted</c> hook — the value is effectively static per
|
||||
/// deployment.
|
||||
/// </summary>
|
||||
public void ApplyRedundancySupport(ConfigRedundancyMode mode)
|
||||
{
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServerRedundancy?.RedundancySupport is not { } node) return;
|
||||
|
||||
// RedundancyMode only declares None / Warm / Hot in v2.0 (non-transparent only per
|
||||
// decision #85). OPC UA's RedundancySupport has more states — clamp to the three we
|
||||
// support and let config-DB CHECK constraints prevent surprises.
|
||||
var support = mode switch
|
||||
{
|
||||
ConfigRedundancyMode.Warm => RedundancySupport.Warm,
|
||||
ConfigRedundancyMode.Hot => RedundancySupport.Hot,
|
||||
_ => RedundancySupport.None,
|
||||
};
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastRedundancySupport == support) return;
|
||||
_lastRedundancySupport = support;
|
||||
node.Value = support;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the self-first peer-URI list onto
|
||||
/// <c>Server.ServerRedundancy.ServerUriArray</c>. Only applies when the SDK created
|
||||
/// <c>ServerRedundancy</c> as <see cref="NonTransparentRedundancyState"/>; on the
|
||||
/// base <see cref="ServerRedundancyState"/> the child is absent and we log-and-skip.
|
||||
/// </summary>
|
||||
public void ApplyServerUriArray(IReadOnlyList<string> serverUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUris);
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServerRedundancy is not NonTransparentRedundancyState ntr
|
||||
|| ntr.ServerUriArray is not { } node)
|
||||
{
|
||||
if (!_warnedMissingServerUriArray)
|
||||
{
|
||||
_warnedMissingServerUriArray = true;
|
||||
_logger.LogWarning(
|
||||
"Server.ServerRedundancy is not NonTransparentRedundancyState — ServerUriArray " +
|
||||
"cannot be published on this server instance. Clients will not see peer URIs " +
|
||||
"on the Part 4 §6.6.2 redundancy node until the redundancy-object type is upgraded.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastServerUriArray is not null && _lastServerUriArray.SequenceEqual(serverUris, StringComparer.Ordinal))
|
||||
return;
|
||||
_lastServerUriArray = [.. serverUris];
|
||||
node.Value = [.. serverUris];
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user