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:
Joseph Doherty
2026-04-24 14:12:19 -04:00
parent 4b0664bd55
commit 69e0d02c72
58 changed files with 3070 additions and 247 deletions

View File

@@ -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>

View File

@@ -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 { }
}
}
}

View File

@@ -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);

View File

@@ -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>();

View File

@@ -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;
}
}

View File

@@ -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) { }
}
}
}

View File

@@ -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)
{

View File

@@ -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; }
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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>