Merge commit '5135a036' into remed/lane-S7

This commit is contained in:
Joseph Doherty
2026-10-22 15:46:38 -04:00
117 changed files with 9847 additions and 994 deletions
+5 -5
View File
@@ -512,16 +512,16 @@ same pass (a UNS reference variable, say) are visible through the index with no
⚠️ **The value fan is now a single message.** It sends one
`DriverInstanceActor.AttributeValueBatchPublished` carrying one item per RawPath, where it previously sent
one `AttributeValuePublished` per ref — 5 000 messages onto the mux that also carries the node's steady
one per-value message per ref — 5 000 messages onto the mux that also carries the node's steady
value ingress, every time one driver flapped. Delivery is unchanged: the mux filters the batch per item
against the same registered-interest map the per-value receive consulted, so only the framing moved. The
**alarm** fan is indexed but deliberately not batched — one driver ref can back several condition NodeIds,
so it still emits one `AlarmQualityUpdate` per condition.
**Consequence worth knowing: `AttributeValuePublished` now has no production sender at all.** This fan was
its last one — everything on the subscription path was already wrapped in `AttributeValueBatchPublished`.
`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays
for tests and direct senders.
**Consequence: the per-value `AttributeValuePublished` record is deleted.** This fan was its last
production sender (everything on the subscription path was already wrapped in
`AttributeValueBatchPublished`), so the record and `DependencyMuxActor`'s per-value receive were removed
(2026-10-22). A single value is a one-item batch; tests build one with `ValuePublishes.Single`.
#### The third fan: the value nodes (archreview 03/S15)
+2 -2
View File
@@ -39,8 +39,8 @@ This was not always true. Until #519 the publish path projected the driver's `ui
`Good`/`Uncertain`/`Bad` enum in `DriverInstanceActor`, and `OtOpcUaNodeManager` re-expanded `Bad` to
a bare `StatusCodes.Bad` (`0x80000000`). Every driver's diagnostic intent survived in logs only —
including the 16 status constants #497 had just corrected across six drivers. The value path now
carries the raw `uint` end to end (`AttributeValuePublished` → `AttributeValueUpdate` →
`IOpcUaAddressSpaceSink.WriteValue`), and severity classification is a helper
carries the raw `uint` end to end (`AttributeValueBatchPublished` → `AttributeValueBatchUpdate` →
`IOpcUaAddressSpaceSink.WriteValues`), and severity classification is a helper
(`OpcUaStatus.IsGood`/`IsUncertain`/`IsBad`) rather than a lossy projection.
**Writing a status code in engine code:** use a named constant on
+23 -1
View File
@@ -198,10 +198,32 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`:
2 s default) quarantines immediately, through the same path (Error log, metric
`reason=stuck`, Bad). A short overrun (a young orphan) stays a plain refusal and
the script resumes once the orphan completes.
- **Cooperative cancellation (`ctx.CancellationToken`).** The guard cancels the run
token when a run times out, and scripts see it as `ctx.CancellationToken`. A
long-running loop that polls `ctx.CancellationToken.IsCancellationRequested` (or calls
`ctx.CancellationToken.ThrowIfCancellationRequested()`) ends its own abandoned run
and frees the slot, instead of holding a pool thread until it returns. A script that
ignores the token is still an orphan. The editor completes the member, and
`CancellationToken` is on the sandbox allow-list.
- **Allocation budget.** A single run allocating more than
`Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the
run returns, so a run that OOMs the process first is not stopped — that needs an
out-of-process runner.
- **Configuration keys and their ranges.** All three knobs are node-wide (the
`Scripting` appsettings section, shared by virtual tags, Calculation tags and
scripted alarms) and are clamped when bound, with one startup Warning per
clamped value:
| Key | Default | Range after the clamp |
|---|---|---|
| `Scripting:MaxAllocBytesPerRun` | `67108864` (64 MB) | `0` (or negative) disables the budget; a positive value below `1048576` (1 MB) becomes 1 MB |
| `Scripting:QuarantineAfterConsecutiveTimeouts` | `3` | `1`–`1000` |
| `Scripting:StuckOrphanQuarantineFactor` | `10` | `0` (or negative) disables the rule; above `1000` becomes `1000`; `NaN` becomes the default |
A Calculation driver may override `maxAllocBytesPerRun` and
`quarantineAfterConsecutiveTimeouts` in its own driver config. Those keys are
layered on the node values and clamped to the same ranges, and an out-of-range
value logs a Warning naming the driver. The stuck factor has no per-driver key.
- **Lifting.** A quarantine lifts when the script source changes or on redeploy,
never by itself.
- **Script-log rate limit.** The cluster-wide script-log fan-out admits 20 entries
@@ -243,7 +265,7 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` +
| Virtual tag | Leaf `Name` (best-effort) |
**Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")`
literal against `DriverInstanceActor.AttributeValuePublished.FullReference`,
literal against `DriverInstanceActor.AttributeValueItem.FullReference`,
which is the `FullName` field from `Tag.TagConfig`
(see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`).
The former UNS-path cascade engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
+3 -3
View File
@@ -93,7 +93,7 @@ Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualT
`ITagUpstreamSource` and `IHistoryWriter` are the two ports value consumers require from their host. Both live in `Core.VirtualTags`. In the v2 actor system:
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes `DriverInstanceActor.AttributeValuePublished` events to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes the items of each `DriverInstanceActor.AttributeValueBatchPublished` to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
## Composition
@@ -104,7 +104,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValueBatchPublished` and routes each item to the `VirtualTagActor` instances that registered interest in that tag ref.
`ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports.
@@ -125,7 +125,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValueBatchPublished` items to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans)
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <para>
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
/// published values (batched, one item per value) keyed by wire-ref (under v3, RawPath), so a driver that
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
@@ -80,6 +80,50 @@ public abstract class ScriptContext
/// </summary>
public abstract ILogger Logger { get; }
/// <summary>
/// The current run's cancellation token. The run guard cancels it when the run exceeds its wall-clock
/// budget, and it is linked to the caller's token, so it is also cancelled when the caller cancels (host
/// shutdown, an actor stopping mid-run). A long-running script can poll <c>ctx.CancellationToken.IsCancellationRequested</c> (or call
/// <c>ThrowIfCancellationRequested()</c>) and end its own abandoned run instead of holding a pool thread
/// until it returns. Cooperative only: a script that never looks at it still runs to completion as an
/// orphan. <see cref="CancellationToken.None"/> outside a run.
/// </summary>
/// <remarks>
/// Scoped per run through an <see cref="AsyncLocal{T}"/>, not stored on the context, because a context
/// object can be reused across runs (the scripted-alarm engine keeps one per alarm). An abandoned orphan
/// therefore keeps seeing its own cancelled token even after a newer run starts on the same context.
/// </remarks>
public CancellationToken CancellationToken => s_runToken.Value;
private static readonly AsyncLocal<CancellationToken> s_runToken = new();
/// <summary>
/// Makes <paramref name="token"/> the script-visible <see cref="CancellationToken"/> on the calling
/// execution context until the returned scope is disposed. Called by <c>ScriptEvaluator.RunAsync</c>
/// around the compiled delegate.
/// </summary>
/// <param name="token">The run token.</param>
/// <returns>A scope that restores the previous token.</returns>
internal static RunTokenScope EnterRun(CancellationToken token)
{
var previous = s_runToken.Value;
s_runToken.Value = token;
return new RunTokenScope(previous);
}
/// <summary>Restores the token that was current before <see cref="EnterRun"/>.</summary>
internal readonly struct RunTokenScope : IDisposable
{
private readonly CancellationToken _previous;
/// <summary>Captures the token to restore.</summary>
/// <param name="previous">The token current before the run.</param>
public RunTokenScope(CancellationToken previous) => _previous = previous;
/// <inheritdoc />
public void Dispose() => s_runToken.Value = _previous;
}
/// <summary>
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
@@ -16,4 +16,10 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- ScriptEvaluator scopes the script-visible run token (ScriptContext.EnterRun). Internal so a script
cannot replace its own token. -->
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting"/>
</ItemGroup>
</Project>
@@ -185,7 +185,13 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
// TResult directly). We surface a Task<TResult> only to keep the existing
// RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in
// Task.Run so a long-running script still honours its wall-clock budget.
var result = _func(globals);
// The run token is the script-visible ctx.CancellationToken for exactly this call. ScriptRunGuard passes
// the token it cancels on timeout, so a polling script can end its own abandoned run.
TResult result;
using (ScriptContext.EnterRun(ct))
{
result = _func(globals);
}
return Task.FromResult(result);
}
@@ -96,9 +96,11 @@ public sealed class ScriptRunGuard
/// <param name="logger">Where the quarantine Error line goes; null ⇒ Serilog's global logger.</param>
public ScriptRunGuard(ScriptingOptions? options = null, ILogger? logger = null)
{
options ??= new ScriptingOptions();
// Clamped here as well as at the bind site, because options also arrive from a Calculation driver's own
// config and from tests. An unclamped factor made the refusal path's timeout * factor overflow.
options = (options ?? new ScriptingOptions()).Sanitized(out _);
_maxAllocBytesPerRun = options.MaxAllocBytesPerRun;
_quarantineThreshold = Math.Max(1, options.QuarantineAfterConsecutiveTimeouts);
_quarantineThreshold = options.QuarantineAfterConsecutiveTimeouts;
_stuckFactor = options.StuckOrphanQuarantineFactor;
_logger = logger ?? Log.Logger;
}
@@ -197,7 +199,11 @@ public sealed class ScriptRunGuard
if (_stuckFactor > 0 && Volatile.Read(ref state.RunStartedTimestamp) is var startedAt and not 0)
{
var running = Stopwatch.GetElapsedTime(startedAt);
var threshold = timeout * _stuckFactor;
// Saturating: even the clamped maximum factor times a huge timeout must not overflow TimeSpan.
var thresholdTicks = timeout.Ticks * _stuckFactor;
var threshold = thresholdTicks >= TimeSpan.MaxValue.Ticks
? TimeSpan.MaxValue
: TimeSpan.FromTicks((long)thresholdTicks);
if (running > threshold)
{
Quarantine(key, state,
@@ -53,4 +53,85 @@ public sealed class ScriptingOptions
/// refusal. Zero or negative disables the rule. Default 10.
/// </summary>
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="StuckOrphanQuarantineFactor"/>.</summary>
public const double MaxStuckOrphanQuarantineFactor = 1000;
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="QuarantineAfterConsecutiveTimeouts"/>.</summary>
public const int MaxQuarantineAfterConsecutiveTimeouts = 1000;
/// <summary>
/// Lower bound <see cref="Sanitized"/> applies to a positive <see cref="MaxAllocBytesPerRun"/>: 1 MB. A
/// smaller budget would quarantine practically every script on its first run.
/// </summary>
public const long MinMaxAllocBytesPerRun = 1024L * 1024;
/// <summary>
/// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs
/// <paramref name="adjustments"/>. <see cref="ScriptRunGuard"/> calls it again, so options that reach the
/// guard by another route (a Calculation driver's own config, a test) are clamped too.
/// <list type="bullet">
/// <item><see cref="StuckOrphanQuarantineFactor"/>: NaN becomes the default, a negative value becomes 0
/// (disabled), and a value above <see cref="MaxStuckOrphanQuarantineFactor"/> becomes that maximum.
/// Unclamped, an absurd factor made <c>timeout * factor</c> throw <see cref="OverflowException"/> on
/// the first refused run.</item>
/// <item><see cref="QuarantineAfterConsecutiveTimeouts"/>: into [1, <see cref="MaxQuarantineAfterConsecutiveTimeouts"/>].</item>
/// <item><see cref="MaxAllocBytesPerRun"/>: a negative value becomes 0 (disabled), and a positive value
/// below <see cref="MinMaxAllocBytesPerRun"/> becomes that minimum.</item>
/// </list>
/// </summary>
/// <param name="adjustments">One line per value that was out of range, naming its key. A negative value that
/// already meant "disabled" is normalised without a line.</param>
/// <returns>A new, clamped instance. This instance is not modified.</returns>
public ScriptingOptions Sanitized(out IReadOnlyList<string> adjustments)
{
var notes = new List<string>();
var factor = StuckOrphanQuarantineFactor;
if (double.IsNaN(factor))
{
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} is NaN; using the default {DefaultStuckOrphanQuarantineFactor}.");
factor = DefaultStuckOrphanQuarantineFactor;
}
else if (factor < 0)
{
factor = 0;
}
else if (factor > MaxStuckOrphanQuarantineFactor)
{
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} {factor} is above the maximum; clamped to {MaxStuckOrphanQuarantineFactor}.");
factor = MaxStuckOrphanQuarantineFactor;
}
var threshold = QuarantineAfterConsecutiveTimeouts;
if (threshold < 1)
{
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is below 1; clamped to 1.");
threshold = 1;
}
else if (threshold > MaxQuarantineAfterConsecutiveTimeouts)
{
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is above the maximum; clamped to {MaxQuarantineAfterConsecutiveTimeouts}.");
threshold = MaxQuarantineAfterConsecutiveTimeouts;
}
var alloc = MaxAllocBytesPerRun;
if (alloc < 0)
{
alloc = 0;
}
else if (alloc > 0 && alloc < MinMaxAllocBytesPerRun)
{
notes.Add($"{SectionName}:{nameof(MaxAllocBytesPerRun)} {alloc} is below the minimum; clamped to {MinMaxAllocBytesPerRun}.");
alloc = MinMaxAllocBytesPerRun;
}
adjustments = notes;
return new ScriptingOptions
{
MaxAllocBytesPerRun = alloc,
QuarantineAfterConsecutiveTimeouts = threshold,
StuckOrphanQuarantineFactor = factor,
};
}
}
@@ -28,6 +28,7 @@ public sealed class ProbeCommand : FocasCommandBase
{
ConfigureLogging();
ValidateOptions();
ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
@@ -19,7 +19,7 @@ public sealed class ReadCommand : FocasCommandBase
/// <summary>Gets the data type to interpret the address as.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <inheritdoc />
@@ -28,6 +28,7 @@ public sealed class ReadCommand : FocasCommandBase
ConfigureLogging();
// Validate numeric option ranges before any driver work.
ValidateOptions();
ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
@@ -18,7 +18,7 @@ public sealed class SubscribeCommand : FocasCommandBase
/// <summary>Gets the data type of the address.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <summary>Gets the polling interval in milliseconds.</summary>
@@ -33,6 +33,7 @@ public sealed class SubscribeCommand : FocasCommandBase
// --interval-ms) before any driver work so a zero/negative interval surfaces as a
// clean CommandException rather than a tight-spinning poll loop.
ValidateOptions(IntervalMs);
ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -20,7 +20,7 @@ public sealed class WriteCommand : FocasCommandBase
/// <summary>Gets the data type of the value to write.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <summary>Gets the value to write.</summary>
@@ -37,6 +37,7 @@ public sealed class WriteCommand : FocasCommandBase
// port/timeout surfaces as a clean CommandException rather than an opaque
// downstream exception.
ValidateOptions();
ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -89,4 +89,17 @@ public abstract class FocasCommandBase : DriverCommandBase
throw new CliFx.Exceptions.CommandException(
$"--interval-ms must be positive (got {iv}).");
}
/// <summary>
/// Refuses an <c>--address</c> / <c>--type</c> pair the driver cannot serve (String on a PMC / PARAM / MACRO
/// address — the driver skips such a tag, so the command would only ever print BadNodeIdUnknown). Same
/// rule as the driver and the AdminUI editor: <see cref="FocasTagDefinitionFactory.DescribeDataTypeMismatch"/>.
/// </summary>
/// <param name="address">The FOCAS address.</param>
/// <param name="type">The data type.</param>
protected static void ValidateDataType(string address, FocasDataType type)
{
if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(type, address) is { } error)
throw new CliFx.Exceptions.CommandException(error);
}
}
@@ -25,6 +25,11 @@ public sealed class ReadCommand : TwinCATTagCommandBase
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
/// <summary>Gets the element count for an array read; null reads a scalar.</summary>
[CommandOption("array-length", Description =
"Read the symbol as a 1-D array of this many --type elements (the leading sub-range when shorter).")]
public int? ArrayLength { get; init; }
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -38,7 +43,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: false);
Writable: false,
ArrayLength: ArrayLength);
var options = BuildOptions([tag]);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
@@ -47,6 +53,9 @@ public sealed class ReadCommand : TwinCATTagCommandBase
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
if (snapshot[0].Value is Array elements)
await console.Output.WriteLineAsync("Elements: [" + string.Join(", ",
elements.Cast<object?>().Select(e => Convert.ToString(e, System.Globalization.CultureInfo.InvariantCulture))) + "]");
}
finally
{
@@ -30,6 +30,16 @@ public sealed class WriteCommand : TwinCATTagCommandBase
IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>Gets the element count for an array write; null writes a scalar.</summary>
[CommandOption("array-length", Description =
"Write the symbol as a 1-D array of this many --type elements. --value then carries the elements " +
"separated by --separator (e.g. -v 1,2,3 --array-length 3); the count must match.")]
public int? ArrayLength { get; init; }
/// <summary>Gets the element separator for an array write.</summary>
[CommandOption("separator", Description = "Element separator for --array-length writes (default ',').")]
public string Separator { get; init; } = ",";
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -48,10 +58,13 @@ public sealed class WriteCommand : TwinCATTagCommandBase
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: true);
Writable: true,
ArrayLength: ArrayLength);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
var parsed = ArrayLength is int count
? ParseArrayValue(Value, DataType, count, Separator)
: ParseValue(Value, DataType);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
try
@@ -113,6 +126,27 @@ public sealed class WriteCommand : TwinCATTagCommandBase
}
}
/// <summary>
/// Parse an array <c>--value</c>: <paramref name="separator"/>-separated elements, each parsed by
/// <see cref="ParseValue"/>. The element count must equal <paramref name="count"/> — the driver
/// refuses a mismatch with BadOutOfRange, but the CLI says so before touching the PLC.
/// </summary>
/// <param name="raw">The raw separated element list.</param>
/// <param name="type">The element type.</param>
/// <param name="count">The expected element count (<c>--array-length</c>).</param>
/// <param name="separator">The element separator.</param>
/// <returns>The parsed elements.</returns>
internal static object[] ParseArrayValue(string raw, TwinCATDataType type, int count, string separator)
{
if (count < 1)
throw new CliFx.Exceptions.CommandException("--array-length must be >= 1.");
var parts = raw.Split(string.IsNullOrEmpty(separator) ? "," : separator);
if (parts.Length != count)
throw new CliFx.Exceptions.CommandException(
$"--value has {parts.Length} element(s) but --array-length is {count}.");
return [.. parts.Select(p => ParseValue(type is TwinCATDataType.String or TwinCATDataType.WString ? p : p.Trim(), type))];
}
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{
"1" or "true" or "on" or "yes" => true,
@@ -117,6 +117,12 @@ public sealed class AbCipDriverOptions
/// device round trips (the whole point: a strictly sequential batch pays one full round
/// trip per tag); lowering it to <c>1</c> restores strictly sequential reads. Values below
/// <c>1</c> are clamped to <c>1</c> at the use site.
/// <para>
/// On the <see cref="AbCipTransport.Native"/> transport the effective window is at least 64: there the
/// window caps how many services one Multiple Service Packet coalesces (the device's shared scheduler packs
/// what is queued, bounded by the connection's request/reply byte budget), so a small value only multiplies
/// round trips. A value above 64 is honoured; a value below it is raised.
/// </para>
/// </summary>
public int MaxConcurrentReads { get; init; } = 8;
@@ -0,0 +1,80 @@
using System.Buffers.Binary;
using System.Collections;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Encodes a whole-array write (review 07 U10): <c>count</c> elements at the element type's wire width,
/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both
/// runtimes so the bytes on the wire are transport-independent.
/// </summary>
/// <remarks>
/// <para><b>Element values CONVERT, they are not exact-matched.</b> ScadaBridge (and any OPC UA client that
/// round-trips what it read) writes CLR lists such as <c>int[]</c> to an INT array — the read path itself
/// publishes INT/SINT elements as <c>int</c> — so each element goes through the same <see cref="Convert"/> the
/// scalar path uses. A value that does not fit is an <see cref="OverflowException"/> (BadOutOfRange) and one that
/// cannot convert a <see cref="FormatException"/> / <see cref="InvalidCastException"/> (BadTypeMismatch), all
/// thrown before any byte is sent.</para>
/// <para>BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays
/// (structures) and structure arrays are not encodable here; the driver refuses them <c>BadNotSupported</c>
/// before a runtime exists.</para>
/// </remarks>
internal static class AbCipArrayCodec
{
/// <summary>Wire width of one element of an encodable array type, or <c>null</c> when the type is not encodable.</summary>
/// <param name="type">The element type.</param>
/// <returns>The element width in bytes, or <c>null</c>.</returns>
public static int? ElementWidth(AbCipDataType type) => type switch
{
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
_ => null,
};
/// <summary>
/// The elements of an array write value, or <c>null</c> when the value is not a list (a scalar, a string, null).
/// Any non-string <see cref="IEnumerable"/> is accepted — <c>int[]</c>, <c>short[]</c>, <c>List&lt;object&gt;</c> …
/// </summary>
/// <param name="value">The client's write value.</param>
/// <returns>The elements, or <c>null</c>.</returns>
public static object?[]? Elements(object? value) => value switch
{
null or string => null,
IEnumerable list => [.. list.Cast<object?>()],
_ => null,
};
/// <summary>Encodes <paramref name="values"/> as <paramref name="type"/> elements.</summary>
/// <param name="type">The element type (must have an <see cref="ElementWidth"/>).</param>
/// <param name="values">The element values; their count is the element count.</param>
/// <returns>The little-endian wire bytes, <c>values.Count × ElementWidth(type)</c> long.</returns>
/// <exception cref="NotSupportedException">The type is not encodable.</exception>
public static byte[] Encode(AbCipDataType type, IReadOnlyList<object?> values)
{
var width = ElementWidth(type)
?? throw new NotSupportedException($"Array writes of {type} elements are not supported.");
var buffer = new byte[values.Count * width];
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
var span = buffer.AsSpan(i * width, width);
switch (type)
{
case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break;
case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break;
case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break;
case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break;
case AbCipDataType.DInt:
case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break;
case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break;
case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break;
case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break;
case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break;
case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break;
}
}
return buffer;
}
}
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
/// Status-code discrimination: <c>AbStatus.Ok</c> and every device refusal (not found, out of bounds,
/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session
/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true".
/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session
/// failure: "handshake failed - Ok = false".
/// </para>
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
@@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
: "CIP session OK (controller reachable; probe tag not found)";
: RefusedProbeMessage(statusCode);
return new(true, msg, sw.Elapsed);
}
@@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
if (IsReachableException(ex))
{
sw.Stop();
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed);
}
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
@@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered — <c>Ok</c>, or any
/// device refusal (<see cref="AbCipRefusals.IsDeviceRefusal(AbStatus)"/>: not found, out of bounds, not
/// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability.
/// </summary>
/// <param name="s">The transport status to classify.</param>
/// <returns><see langword="true"/> if the status confirms CIP reachability.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s);
/// <summary>The Test-Connect message for a controller that answered but refused the probe tag.</summary>
/// <param name="status">The refusal.</param>
/// <returns>The message.</returns>
private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound
? "CIP session OK (controller reachable; probe tag not found)"
: $"CIP session OK (controller reachable; probe tag refused: {status})";
/// <summary>
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
@@ -198,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
internal static bool IsReachableException(AbTransportException ex)
{
ArgumentNullException.ThrowIfNull(ex);
return IsReachableStatus(ex.Status);
// A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Thrown by <see cref="AbCipDriver.InitializeAsync"/> when a shutdown, dispose or newer initialization retired the
/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
/// <see cref="OperationCanceledException"/>: nobody cancelled this call, and a caller that treats cancellation as
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
/// </summary>
/// <remarks>
/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
/// <c>ShouldHandle</c> (<c>DriverResiliencePipelineBuilder</c>) and, if its generation still matched, the actor's
/// <c>RecordFault</c>. That is practically unreachable — <c>DriverInstanceActor</c> runs initialisation single-flight
/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
/// it there is a runtime-side (Lane A) change if it is ever wanted.
/// </remarks>
/// <param name="message">The detail.</param>
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
@@ -0,0 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (<c>ErrorNotImplemented</c>) — before or
/// without any reply from the controller. It carries <see cref="AbStatus.Unsupported"/> (so it maps to
/// <c>BadNotSupported</c>) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
/// writes no health, does NOT advance <c>LastSuccessfulRead</c>, records nothing in the connect backoff, and is not
/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
/// </summary>
/// <param name="message">The refusal detail.</param>
/// <param name="inner">The underlying exception, if any.</param>
public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
: AbTransportException(AbStatus.Unsupported, message, inner);
@@ -0,0 +1,42 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by
/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation.
/// </summary>
/// <remarks>
/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed
/// tag, <c>Arr[999]</c>, a read-protected tag, a type the authored config does not match). It is per-tag Bad,
/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll),
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
/// <para>
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
/// comms faults.
/// </para>
/// </remarks>
internal static class AbCipRefusals
{
/// <summary>True when <paramref name="status"/> proves the controller answered but refused this tag.</summary>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds
or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch
or AbStatus.Unsupported;
/// <summary>True when <paramref name="status"/> (an <see cref="AbStatus"/> value) is a device refusal.</summary>
/// <param name="status">The runtime status word.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status);
/// <summary>True when <paramref name="ex"/> is an <see cref="AbTransportException"/> carrying a device refusal.</summary>
/// <param name="ex">The exception.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
/// <remarks>An <see cref="AbCipLocalRefusalException"/> is never a device refusal: nothing reached the device.</remarks>
public static bool IsDeviceRefusal(Exception ex) =>
ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
}
@@ -15,6 +15,7 @@ public static class AbCipStatusMapper
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotReadable = 0x803A0000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
@@ -51,4 +52,14 @@ public static class AbCipStatusMapper
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
AbOpcUaStatusMap.MapException(ex);
/// <summary>
/// Status for a READ the controller refused: as <see cref="MapStatus(AbStatus)"/>, except that a
/// <see cref="AbStatus.NotAllowed"/> answer to a read is <see cref="BadNotReadable"/> (the shared table's
/// <c>BadNotWritable</c> describes a refused write).
/// </summary>
/// <param name="status">The refusal status.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapReadStatus(AbStatus status) =>
status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status);
}
@@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </remarks>
public static class AbCipUdtMemberLayout
{
/// <summary>Layouts already computed, keyed by the parent definition's member list (reference identity).</summary>
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IReadOnlyList<AbCipStructureMember>, LayoutBox> Cache = new();
/// <summary>Holds a computed layout, including a "not groupable" (<c>null</c>) verdict.</summary>
private sealed class LayoutBox(IReadOnlyDictionary<string, int>? offsets)
{
public IReadOnlyDictionary<string, int>? Offsets { get; } = offsets;
}
/// <summary>
/// <see cref="TryBuild"/>, computed once per member list (review 07 P11: the read planner rebuilt the layout —
/// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per
/// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds
/// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with
/// them (<see cref="System.Runtime.CompilerServices.ConditionalWeakTable{TKey,TValue}"/>).
/// </summary>
/// <param name="members">The parent definition's declared members.</param>
/// <returns>The member offsets, or <c>null</c> when the members cannot be laid out by declaration.</returns>
public static IReadOnlyDictionary<string, int>? GetOrBuild(IReadOnlyList<AbCipStructureMember> members)
{
ArgumentNullException.ThrowIfNull(members);
return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets;
}
/// <summary>
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
/// if any member type is unsupported for declaration-only layout.
@@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
/// <param name="bitIndex">Bit index for BOOL-within-DINT insertion, or null.</param>
/// <param name="value">Value to encode.</param>
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
/// <summary>
/// Encode a WHOLE-array write of <paramref name="values"/> (<c>values.Count</c> elements of
/// <paramref name="type"/>, at the element width — see <see cref="AbCipArrayCodec"/>) into the local buffer.
/// Callers pair this with <see cref="WriteAsync"/>. A runtime that cannot write arrays throws
/// <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="type">The array element type.</param>
/// <param name="values">The element values; already length-checked against the tag's element count.</param>
void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
throw new NotSupportedException($"{GetType().Name} does not support array writes.");
}
/// <summary>
@@ -14,11 +14,21 @@ internal static class LibplctagStatusTranslation
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
// #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
// session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
// the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
Status.ErrorNotFound => AbStatus.NotFound,
Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
// ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
// only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
// NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
// device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
@@ -70,9 +80,21 @@ internal static class LibplctagStatusTranslation
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>
/// The transport exception for a libplctag failure: <c>ErrorNotImplemented</c> becomes an
/// <see cref="AbCipLocalRefusalException"/> (a library refusal, no I/O); everything else an
/// <see cref="AbTransportException"/> carrying the translated status.
/// </summary>
/// <param name="ex">The libplctag exception.</param>
/// <returns>The exception to throw.</returns>
public static AbTransportException FromException(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
: new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag operation.</param>
@@ -80,7 +102,7 @@ internal static class LibplctagStatusTranslation
public static async Task<T> Wrap<T>(Func<Task<T>> op)
{
try { return await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
@@ -90,7 +112,7 @@ internal static class LibplctagStatusTranslation
public static T WrapSync<T>(Func<T> op)
{
try { return op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
@@ -98,6 +120,6 @@ internal static class LibplctagStatusTranslation
public static void WrapSync(Action op)
{
try { op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
catch (LibPlcTagException ex) { throw FromException(ex); }
}
}
@@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = BuildTag(p);
_boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool
&& NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _);
}
/// <summary>
/// A scalar BOOL authored over a BOOL-array element (<c>Bits[n]</c>). libplctag reads it as whatever the
/// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD
/// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode
/// "low byte != 0" as Good; it is refused (<c>BadNotSupported</c>) when a DWORD came back. The native transport
/// (the default) addresses the right DWORD and decodes the bit.
/// </summary>
private readonly bool _boolArrayElement;
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The new, uninitialised libplctag tag.</returns>
@@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(offset) != 0,
: _boolArrayElement && offset == 0 && _tag.GetSize() == 4
? throw new AbTransportException(AbStatus.Unsupported,
$"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.")
: _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
@@ -221,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_ => 4,
};
/// <inheritdoc />
/// <remarks>
/// The codec's bytes are copied into the buffer the last read sized (<c>elem_count</c> elements), so the write
/// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
/// (<see cref="AbStatus.TooSmall"/>) before anything is changed.
/// </remarks>
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
LibplctagStatusTranslation.WrapSync(() =>
{
var bytes = AbCipArrayCodec.Encode(type, values);
var size = _tag.GetSize();
if (size < bytes.Length)
throw new AbTransportException(AbStatus.TooSmall,
$"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
});
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
@@ -41,6 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private int? _implicitBit;
private byte[]? _path;
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
// array is packed. Null for every other tag.
private int? _boolElementBit;
// Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
// original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
private byte[]? _dwordPath;
private byte[]? _elementPath;
/// <summary>How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.</summary>
private enum BoolLayout
{
/// <summary>No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.</summary>
Unsettled,
/// <summary>The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.</summary>
Packed,
/// <summary>The controller answered 0xC1: address element n, decode its byte.</summary>
PerElement,
}
// #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
// A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
// online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
// with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
private BoolLayout _boolLayout;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
@@ -51,6 +79,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
private ushort _writeElements = 1;
private string? _pendingString;
private bool _disposed;
@@ -94,16 +123,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
internal int? ImplicitBit => _implicitBit;
// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
// tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
// Performs one read to prove the session works and learn the tag's type.
//
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
// tag's configuration, not the device, and must not black out the device's other tags. A read the
// controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
// proves the tag exists, so it completes the initialization with that status rather than throwing:
// a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
// holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
// refusal.
// controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds,
// NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the
// initialization with that status rather than throwing: a throw here makes the driver dispose the runtime
// and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a
// mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime.
/// <inheritdoc />
public async Task InitializeAsync(CancellationToken cancellationToken)
{
@@ -116,7 +144,20 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
split = CipPath.SplitBitSelector(_p.TagName);
path = CipPath.EncodeSymbolic(split.Name);
var name = split.Name;
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
{
_elementPath = CipPath.EncodeSymbolic(name);
_dwordPath = CipPath.EncodeSymbolic(dwordName);
_boolElementBit = bit;
_boolLayout = BoolLayout.Unsettled;
path = _dwordPath;
}
else
{
path = CipPath.EncodeSymbolic(name);
}
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -137,20 +178,45 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
{
// The controller answered — the tag exists. _status already holds the refusal.
// The controller answered. _status already holds the refusal.
}
}
/// <inheritdoc />
public async Task ReadAsync(CancellationToken cancellationToken)
public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
if (_boolElementBit is not null)
{
BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
: type == LogixTypeCode.Bool ? BoolLayout.PerElement
: null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
if (shown is { } layout)
{
var askedPacked = ReferenceEquals(path, _dwordPath);
SetBoolLayout(layout);
if ((layout == BoolLayout.Packed) != askedPacked)
{
// The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
// packed one) — not this element. Discard it and ask again under the layout it just showed.
if (mayRelayout)
{
await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
return;
}
throw new AbTransportException(AbStatus.TypeMismatch,
$"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
}
}
}
_data = data;
_typeCode = type;
_structHandle = handle;
@@ -164,6 +230,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
}
/// <summary>Settles a BOOL-array element tag's layout and selects the matching request path.</summary>
private void SetBoolLayout(BoolLayout layout)
{
_boolLayout = layout;
_path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
}
/// <summary>
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
/// authored type is unknown or a structure, which sends the read alone rather than guess.
@@ -196,6 +269,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
// DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
// (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
path = Path();
}
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
@@ -204,7 +285,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
throw new AbTransportException(AbStatus.Unsupported,
throw new AbCipLocalRefusalException(
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
@@ -213,7 +294,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
?? throw new AbTransportException(AbStatus.Unsupported,
?? throw new AbCipLocalRefusalException(
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
@@ -223,17 +304,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
if (width > 0 && width != buffer.Length)
if (width > 0 && width * _writeElements != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
if (type == LogixTypeCode.BoolArray)
if (type == LogixTypeCode.BoolArray && _writeElements == 1)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
// U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -276,7 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
@@ -315,10 +397,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
};
}
/// <inheritdoc />
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
{
ArgumentNullException.ThrowIfNull(values);
_writeBuffer = null;
_pendingString = null;
var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
_writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
_writeBuffer = buffer;
}
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
_writeElements = 1;
_pendingString = null;
switch (type)
{
@@ -410,6 +504,47 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
/// <summary>
/// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
/// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
/// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
/// </summary>
private bool DecodeScalarBool(int offset)
{
if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
{
if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
throw new AbTransportException(AbStatus.TypeMismatch,
$"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
}
return Span(offset, 1)[0] != 0;
}
/// <summary>
/// Splits a BOOL-array element reference <c>Name[n]</c> (one non-negative subscript, last segment) into the
/// DWORD it lives in (<c>Name[n / 32]</c>) and its bit within that DWORD (<c>n % 32</c>). A Logix BOOL array
/// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs.
/// </summary>
/// <param name="name">The tag name, bit selector already split off.</param>
/// <param name="dwordName">The name addressing the containing DWORD.</param>
/// <param name="bit">The element's bit within that DWORD.</param>
/// <returns><see langword="true"/> when <paramref name="name"/> ends in a single subscript.</returns>
internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit)
{
dwordName = name;
bit = 0;
if (!name.EndsWith(']')) return false;
var open = name.LastIndexOf('[');
if (open <= 0) return false;
var inner = name[(open + 1)..^1];
if (inner.Length == 0 || !inner.All(char.IsAsciiDigit)
|| !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n))
return false;
dwordName = $"{name[..open]}[{n / 32}]";
bit = n % 32;
return true;
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
@@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
/// <param name="scripting">The node-wide <c>Scripting</c> options every Calculation driver inherits; a key
/// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults.</param>
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRoot = null)
ScriptRootLogger? scriptRoot = null,
ScriptingOptions? scripting = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting));
}
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
@@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null);
/// <summary>The full overload: also takes the node-wide <c>Scripting</c> options the driver inherits.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
/// <param name="nodeScripting">The node-wide run-guard options; null ⇒ the compiled defaults.</param>
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -71,15 +86,14 @@ public static class CalculationDriverFactoryExtensions
?? throw new InvalidOperationException(
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
// Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
// explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
ScriptingOptions? scripting = null;
if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
{
scripting = new ScriptingOptions();
if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
}
var scripting = ResolveScripting(
dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting, out var adjustments);
var logger = loggerFactory?.CreateLogger<CalculationDriver>();
// An authored value is never changed silently: the run guard would clamp it anyway, without a word.
foreach (var adjustment in adjustments)
logger?.LogWarning(
"Calculation driver {DriverInstanceId}: its run-guard config was out of range and is clamped: {Adjustment}",
driverInstanceId, adjustment);
var options = new CalculationDriverOptions
{
@@ -89,9 +103,37 @@ public static class CalculationDriverFactoryExtensions
Scripting = scripting,
};
return new CalculationDriver(
options, driverInstanceId, scriptRoot,
logger: loggerFactory?.CreateLogger<CalculationDriver>());
return new CalculationDriver(options, driverInstanceId, scriptRoot, logger: logger);
}
/// <summary>
/// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored
/// in the driver's config overrides only its own knob, on a copy, so the shared node object is never
/// written. With neither, the result is null and the guard uses its compiled defaults.
/// </summary>
/// <param name="maxAllocBytesPerRun">The driver config's <c>maxAllocBytesPerRun</c>, or null.</param>
/// <param name="quarantineAfterConsecutiveTimeouts">The driver config's <c>quarantineAfterConsecutiveTimeouts</c>, or null.</param>
/// <param name="nodeScripting">The node-wide options, or null.</param>
/// <param name="adjustments">The clamps applied to the driver's OWN keys (the node options were already
/// clamped and logged at bind), for the caller to log. Empty when nothing was out of range.</param>
/// <returns>The options to hand the driver, or null.</returns>
internal static ScriptingOptions? ResolveScripting(
long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting,
out IReadOnlyList<string> adjustments)
{
adjustments = [];
if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting;
var resolved = new ScriptingOptions();
if (nodeScripting is not null)
{
resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun;
resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts;
resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor;
}
if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc;
if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n;
return resolved.Sanitized(out adjustments);
}
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
@@ -53,6 +53,9 @@ public static class FocasTagDefinitionFactory
// Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
// A data type no read of this address can produce (String on PMC / PARAM / MACRO) rejects the tag:
// served, it published a boxed number at Good into a String node.
if (DescribeDataTypeMismatch(dataType, address) is not null) return false;
// FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL):
// absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate
// (default-false, so a missing key stays read-only — the note is preserved).
@@ -73,6 +76,26 @@ public static class FocasTagDefinitionFactory
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// The ONE address/data-type compatibility rule, shared by the mapper (and so the driver's init and the
/// deploy inspector), the AdminUI tag editor and the CLI. <see cref="FocasDataType.String"/> is only
/// servable from the fixed tree (Identity / Program name / mode text …): a PMC, parameter or macro read
/// returns a number, which a String node would receive at Good. An <c>ALARM:</c> tag has no value, so its
/// data type is irrelevant; an unparseable address is left to the address check, which names it better.
/// </summary>
/// <param name="dataType">The authored data type.</param>
/// <param name="address">The authored address.</param>
/// <returns>The reason the pair cannot be served, or <see langword="null"/> when it can.</returns>
public static string? DescribeDataTypeMismatch(FocasDataType dataType, string? address)
{
if (dataType != FocasDataType.String) return null;
var parsed = FocasAddress.TryParse(address);
if (parsed is null || parsed.Kind is FocasAreaKind.Fixed or FocasAreaKind.Alarm) return null;
return $"FOCAS data type 'String' is not readable at '{address!.Trim()}' — PMC, PARAM: and MACRO: reads " +
"return numbers (String is only valid on FIXED: paths such as FIXED:Identity/SeriesNumber). " +
"Choose Bit / Byte / Int16 / Int32 / Float32 / Float64.";
}
/// <summary>
/// The tag's cross-driver <c>alarm</c> object, or <see langword="null"/> when it carries none.
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
@@ -236,6 +259,10 @@ public static class FocasTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType") is { } w) rejections.Add(w);
else if (addr.ValueKind == JsonValueKind.String
&& TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)
&& DescribeDataTypeMismatch(dataType, addr.GetString()) is { } mismatch)
rejections.Add(mismatch + " The driver will skip this tag and it will read BadNodeIdUnknown.");
}
/// <summary>
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,9 @@ public sealed class SynchronizedFocasClient : IFocasClient
private readonly IFocasClient _inner;
private readonly TimeSpan _callTimeout;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Ops holding or queued for <see cref="_gate"/> — what <see cref="DisposeWhenIdleAsync"/> drains.</summary>
private int _pending;
private int _disposed;
/// <summary>Wraps <paramref name="inner"/> with per-device serialization + a per-call timeout.</summary>
/// <param name="inner">The underlying FOCAS client to serialize access to.</param>
@@ -115,26 +118,66 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// <summary>Disposes the inner client and the call gate.</summary>
/// <summary>
/// Disposes the inner client. Ops still queued on the call gate then fail with
/// <see cref="ObjectDisposedException"/> instead of running on a disposed socket. The gate itself is NOT
/// disposed: an op that already holds it releases it on the way out, and releasing a disposed
/// <see cref="SemaphoreSlim"/> threw out of a read that had otherwise completed.
/// </summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_inner.Dispose();
_gate.Dispose();
}
/// <summary>
/// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most
/// <paramref name="maxWait"/>. Used by the handle-recycle loop, which detaches the client under the device's
/// connect gate first — so nothing new can reach it — and then must not kill a read that is mid-flight on
/// it (that read used to surface BadCommunicationError + Degraded on every recycle tick).
/// </summary>
/// <param name="maxWait">Upper bound on the wait; the client is disposed regardless once it passes.</param>
/// <returns>A task that completes when the client is disposed.</returns>
public async Task DisposeWhenIdleAsync(TimeSpan maxWait)
{
var deadline = DateTime.UtcNow + maxWait;
while (Volatile.Read(ref _pending) > 0 && DateTime.UtcNow < deadline)
await Task.Delay(5, CancellationToken.None).ConfigureAwait(false);
Dispose();
}
/// <summary>Gets the number of ops currently holding or queued for the call gate. Test seam.</summary>
internal int PendingOperations => Volatile.Read(ref _pending);
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(SynchronizedFocasClient), "The FOCAS client was recycled or disposed.");
}
// Gate only — the caller already governs the budget (connect timeout arg / probe linked token).
private async Task<T> RunGatedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { return await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); return await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
private async Task RunGatedAsync(Func<CancellationToken, Task> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
// Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is
@@ -142,16 +185,22 @@ public sealed class SynchronizedFocasClient : IFocasClient
// their own token's IsCancellationRequested.
private async Task<T> RunBoundedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
Interlocked.Increment(ref _pending);
try
{
if (_callTimeout <= TimeSpan.Zero)
return await op(ct).ConfigureAwait(false);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ThrowIfDisposed();
if (_callTimeout <= TimeSpan.Zero)
return await op(ct).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_callTimeout);
return await op(linked.Token).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_callTimeout);
return await op(linked.Token).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
finally { _gate.Release(); }
finally { Interlocked.Decrement(ref _pending); }
}
}
@@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The raw parameter payload bytes, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
short dataNumber,
int dataNumber,
short axis = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var secondArg = axis == 0 ? dataNumber : axis;
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
var block = await SendSingleRequestAsync(callTimeout.Token, ParameterRequest(dataNumber, axis, EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => payload);
}
/// <summary>
/// The <c>cnc_rdparam</c> (0x000e) request block. The data number travels in the 32-bit
/// <c>Arg1</c> UNNARROWED — the request block has always carried <see cref="int"/> arguments, and
/// a <c>short</c> cast in the adapter once asked the CNC for a different (wrapped) number than the
/// one authored. A global parameter repeats the number in <c>Arg2</c>; an axis parameter carries
/// the axis there.
/// </summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="axis">Axis selector; zero means a global parameter.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) =>
new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId);
/// <summary>
/// The <c>cnc_rdmacro</c> (0x0015) request block: the variable number, unnarrowed, in both 32-bit
/// arguments (start / end of a one-variable range) — byte-identical to the live 31i-B capture for #500.
/// </summary>
/// <param name="number">The macro variable number.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock MacroRequest(int number, ushort pathId) =>
new(0x0015, number, number, PathId: pathId);
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="type">The FOCAS parameter type code.</param>
@@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
short dataNumber,
int dataNumber,
short type = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 8-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<byte>> ReadParameterByteAsync(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -522,7 +545,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 16-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<short>> ReadParameterInt16Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -537,7 +560,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 32-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<int>> ReadParameterInt32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -552,7 +575,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed single-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<float>> ReadParameterFloat32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 4
@@ -567,7 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed double-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<double>> ReadParameterFloat64Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 8
@@ -582,14 +605,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The macro variable value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<WireMacro>> ReadMacroAsync(
short number,
int number,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0015,
{
var request = MacroRequest(number, EffectivePathId(pathId));
return ReadSingleWithTimeoutAsync(
request.Command,
payload => ParseMacro(number, payload),
cancellationToken, timeout, EffectivePathId(pathId), number, number);
cancellationToken, timeout, request.PathId, request.Arg1, request.Arg2);
}
/// <summary>
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
@@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="payload">The response-block payload.</param>
/// <returns>The decoded macro.</returns>
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
internal static WireMacro ParseMacro(short number, byte[] payload)
internal static WireMacro ParseMacro(int number, byte[] payload)
{
RequireLength(payload, 8, "cnc_rdmacro");
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
@@ -1111,15 +1137,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="count">The requested record count.</param>
/// <returns>The decoded alarms.</returns>
/// <exception cref="FocasWireException">The payload length matches neither record layout.</exception>
/// <remarks>
/// <para><b>Choosing the layout.</b> The response block carries no record count, so a length that is a
/// multiple of BOTH strides (1,520 B = 20 × 76 = 19 × 80, and its multiples) is ambiguous; picking the
/// vendor stride on <c>Length % 76 == 0</c> alone decoded 19 legacy records as 20 garbage ones. The choice
/// now uses what IS known: (1) the record count the REQUEST asked for — a reply never holds more, so a
/// layout implying more records than <paramref name="count"/> is ruled out; (2) each layout's own field
/// invariants, checked on every record (<see cref="LegacyRecordsValid"/> / <see cref="VendorRecordsValid"/>).
/// Only when both readings remain plausible does the vendor layout win, as before.</para>
/// </remarks>
internal static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
{
if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
if (payload.Length == 0) return [];
var vendor = payload.Length % VendorAlarmStride == 0 && payload.Length / VendorAlarmStride <= Math.Max((int)count, 1);
var legacy = payload.Length % LegacyAlarmStride == 0 && payload.Length / LegacyAlarmStride <= Math.Max((int)count, 1);
if (vendor && legacy)
{
// Ambiguous length: a legacy record's int32 type/axis/length fields must each fit the short they are
// read into and its length must fit the 64-byte message — a vendor payload read at the legacy stride
// fails that on its first non-empty message. Prefer the reading whose every record is self-consistent.
var legacyOk = LegacyRecordsValid(payload);
var vendorOk = VendorRecordsValid(payload);
if (legacyOk && !vendorOk) vendor = false;
else if (legacyOk && vendorOk && LengthsMatchText(payload, LegacyAlarmStride, 12, 4, 16)
&& !LengthsMatchText(payload, VendorAlarmStride, 10, 2, 12)) vendor = false;
}
if (vendor) return ParseVendorAlarms(payload, count);
if (legacy) return ParseLegacyWireAlarms(payload, count);
if (payload.Length % VendorAlarmStride == 0 || payload.Length % LegacyAlarmStride == 0)
{
// Fits a stride but holds more records than were requested: fall back to the stride it fits, capped
// at the count, exactly as before this check existed.
return payload.Length % VendorAlarmStride == 0
? ParseVendorAlarms(payload, count)
: ParseLegacyWireAlarms(payload, count);
}
throw new FocasWireException(
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
"record layout; refusing to read it as an empty alarm list.", rc: null);
}
private const int VendorAlarmStride = 76;
private const int LegacyAlarmStride = 80;
private const int AlarmMessageBytes = 64;
/// <summary>Every 80-byte record's int32 type / axis / length fit a short, and the length fits the message.</summary>
private static bool LegacyRecordsValid(byte[] payload)
{
for (var o = 0; o + LegacyAlarmStride <= payload.Length; o += LegacyAlarmStride)
{
var type = ReadInt32(payload, o + 4);
var axis = ReadInt32(payload, o + 8);
var length = ReadInt32(payload, o + 12);
if (type is < short.MinValue or > short.MaxValue) return false;
if (axis is < short.MinValue or > short.MaxValue) return false;
if (length is < 0 or > AlarmMessageBytes) return false;
}
return true;
}
/// <summary>Every 76-byte record's i16 message length fits the 64-byte message.</summary>
private static bool VendorRecordsValid(byte[] payload)
{
for (var o = 0; o + VendorAlarmStride <= payload.Length; o += VendorAlarmStride)
{
var length = ReadInt16(payload, o + 10);
if (length is < 0 or > AlarmMessageBytes) return false;
}
return true;
}
/// <summary>Every record's declared message length equals the length of the text actually in its message field.</summary>
private static bool LengthsMatchText(byte[] payload, int stride, int lengthOffset, int lengthWidth, int messageOffset)
{
for (var o = 0; o + stride <= payload.Length; o += stride)
{
var length = lengthWidth == 4 ? ReadInt32(payload, o + lengthOffset) : ReadInt16(payload, o + lengthOffset);
var text = FocasWireProtocol.ReadAscii(payload.AsSpan(o + messageOffset, AlarmMessageBytes));
if (length != text.Length) return false;
}
return true;
}
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
@@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
int DataNumber,
short Type,
int Value);
@@ -184,7 +184,7 @@ public sealed record WireParameter(
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Number,
int Value,
short Decimal)
{
@@ -80,6 +80,7 @@ public sealed class WireFocasClient : IFocasClient
var area = FocasPmcAreaLookup.FromLetter(letter);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
if (start < 0 || end > ushort.MaxValue || end < start) return (null, FocasStatusMapper.BadOutOfRange);
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
@@ -334,18 +335,38 @@ public sealed class WireFocasClient : IFocasClient
// ---- PMC / Parameter / Macro read paths ------------------------------------------
/// <summary>
/// The 16-bit byte range <c>pmc_rdpmcrng</c> addresses for a slot of <paramref name="width"/> bytes at
/// <paramref name="number"/>. False when any byte of it falls outside 0..65535 — the range is refused
/// rather than wrapped onto a different PMC address (an Unknown-series device admits any number).
/// </summary>
/// <param name="number">The authored PMC byte address.</param>
/// <param name="width">The slot width in bytes (≥ 1).</param>
/// <param name="start">The first byte, when representable.</param>
/// <param name="end">The last byte, when representable.</param>
/// <returns>True when the whole slot is addressable.</returns>
internal static bool TryPmcRange(int number, int width, out ushort start, out ushort end)
{
start = end = 0;
var last = (long)number + Math.Max(width, 1) - 1;
if (number < 0 || last > ushort.MaxValue) return false;
start = (ushort)number;
end = (ushort)last;
return true;
}
private async Task<(object? value, uint status)> ReadPmcAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
var start = (ushort)address.Number;
// pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs
// the range widened to its byte width, else the value parser gets too few bytes → 0 values
// → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned
// a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25.
var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1);
if (!TryPmcRange(address.Number, FocasWireClient.PmcByteWidth((short)dataType), out var start, out var end))
return (null, FocasStatusMapper.BadOutOfRange);
try
{
@@ -373,23 +394,23 @@ public sealed class WireFocasClient : IFocasClient
switch (type)
{
case FocasDataType.Byte:
var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var b = await _wire.ReadParameterByteAsync(address.Number, 0, cancellationToken).ConfigureAwait(false);
return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
case FocasDataType.Int16:
var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var s = await _wire.ReadParameterInt16Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
case FocasDataType.Float32:
var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var f = await _wire.ReadParameterFloat32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
case FocasDataType.Float64:
var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var d = await _wire.ReadParameterFloat64Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
case FocasDataType.Bit when address.BitIndex is int bit:
var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var bi = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
default:
var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
var i = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
}
}
@@ -404,7 +425,7 @@ public sealed class WireFocasClient : IFocasClient
{
try
{
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
var result = await _wire.ReadMacroAsync(address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
return MapMacro(result.Value, type, address.BitIndex);
@@ -469,6 +469,20 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
try
{
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
// A STRING / WSTRING is written as raw bytes at the PLC's own declared width, encoded by the
// same codec the read decodes with: Windows-1252 (or UTF-8 when the target says so) rather
// than the host's ANSI code page, NUL-padded so no stale tail survives, and refused
// (OverflowException → BadOutOfRange) when it does not fit STRING(n).
var (shape, _, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
var (planStatus, encoded) = PlanStringWrite(type, shape, value, StringsAreUtf8());
if (planStatus != TwinCATStatusMapper.Good) return planStatus;
return await WriteRawAsync(symbolPath, symbol!, encoded, cancellationToken).ConfigureAwait(false);
}
var converted = ConvertForWrite(type, value);
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
.ConfigureAwait(false);
@@ -482,6 +496,123 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// The pure encode plan of a STRING / WSTRING write against the RESOLVED symbol: the declared kind must
/// match the PLC's (<see cref="TwinCATTypeGuard.CheckRead"/>, else <c>BadTypeMismatch</c>), then the value is
/// encoded at the PLC's byte width by <see cref="TwinCATStringCodec"/>.
/// </summary>
/// <param name="type">The declared String / WString type.</param>
/// <param name="shape">The resolved PLC symbol shape.</param>
/// <param name="value">The written value.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data.</param>
/// <returns>Good with the bytes to write, or the refusal status with no bytes.</returns>
/// <exception cref="OverflowException">The value does not fit the PLC string.</exception>
internal static (uint status, byte[] bytes) PlanStringWrite(
TwinCATDataType type, TwinCATPlcSymbolShape shape, object? value, bool utf8)
{
if (TwinCATTypeGuard.CheckRead(type, shape) is not null) return (TwinCATStatusMapper.BadTypeMismatch, []);
var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
return (TwinCATStatusMapper.Good, type == TwinCATDataType.WString
? TwinCATStringCodec.EncodeWString(text, shape.ByteSize)
: TwinCATStringCodec.EncodeString(text, shape.ByteSize, utf8));
}
/// <summary>
/// The pure encode plan of an array write against the RESOLVED PLC array: the layout comes from
/// <see cref="TwinCATTypeGuard.CheckArray"/> (the array read's rule, else <c>BadTypeMismatch</c>) and the
/// bytes from <see cref="TwinCATArrayCodec.Encode"/>.
/// </summary>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements.</param>
/// <param name="shape">The resolved PLC symbol shape.</param>
/// <param name="array">The resolved PLC array shape, or null for a non-array symbol.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data.</param>
/// <returns>Good with the bytes to write, or the refusal status with no bytes.</returns>
/// <exception cref="OverflowException">A string element does not fit its stride.</exception>
internal static (uint status, byte[] bytes) PlanArrayWrite(TwinCATDataType type, IReadOnlyList<object> elements,
TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, bool utf8)
{
if (TwinCATTypeGuard.CheckArray(type, elements.Count, shape, array, out var layout) is not null)
return (TwinCATStatusMapper.BadTypeMismatch, []);
return (TwinCATStatusMapper.Good, TwinCATArrayCodec.Encode(type, elements, layout, utf8));
}
/// <inheritdoc />
public async Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken cancellationToken)
{
try
{
// Plan the layout from the RESOLVED PLC array exactly as the array read does, so the element
// stride (incl. a STRING(n) element's n+1) and the strict element-type rule are shared.
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
var (planStatus, bytes) = PlanArrayWrite(type, elements, shape, array, StringsAreUtf8());
if (planStatus != TwinCATStatusMapper.Good) return planStatus;
return await WriteRawAsync(symbolPath, symbol!, bytes, cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <inheritdoc />
public async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken)
{
try
{
// Resolve the parent so the RMW happens at its OWN width (the notification path's model):
// the old 32-bit UDInt round-trip wrote a 16-bit parent's neighbours and overflowed on a
// signed parent with its sign bit set.
var (parent, _, symbol, status) = await DescribeSymbolAsync(parentPath, cancellationToken)
.ConfigureAwait(false);
if (status != TwinCATStatusMapper.Good) return status;
if (TwinCATBitAccess.CheckParent(bit, parent) is not null)
return TwinCATBitAccess.ApplyBitWrite(parent, ReadOnlySpan<byte>.Empty, bit, value, out _);
var (bytes, readStatus) = await ReadRawAsync(parentPath, symbol!, parent.ByteSize, cancellationToken)
.ConfigureAwait(false);
if (readStatus != TwinCATStatusMapper.Good) return readStatus;
var modify = TwinCATBitAccess.ApplyBitWrite(parent, bytes, bit, value, out var updated);
if (modify != TwinCATStatusMapper.Good) return modify;
return await WriteRawAsync(parentPath, symbol!, updated, cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <summary>
/// Writes raw bytes to a resolved symbol — the mirror of <see cref="ReadRawAsync"/>: by its index
/// group/offset, or through a fresh variable handle for a handle-addressed symbol (0xF005).
/// </summary>
private async Task<uint> WriteRawAsync(
string symbolPath, IAdsSymbol symbol, ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.WriteAsync(symbol.IndexGroup, symbol.IndexOffset, data, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)direct.ErrorCode);
}
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (handle.ErrorCode != AdsErrorCode.NoError) return MapAndSignal((uint)handle.ErrorCode);
try
{
var write = await _client.WriteAsync(handle.Handle, data, cancellationToken).ConfigureAwait(false);
return write.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)write.ErrorCode);
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
/// <inheritdoc />
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
@@ -523,8 +654,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
throw RegistrationFailure(registerPath, "resolving the parent word", parentStatus);
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]);
@@ -538,8 +668,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
throw RegistrationFailure(symbolPath, "resolving the symbol", symbolStatus);
if (arrayCount is int count)
{
@@ -582,8 +711,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
throw RegistrationFailure(registerPath, $"AddDeviceNotificationEx (ADS error {result.ErrorCode})",
MapAndSignal((uint)result.ErrorCode));
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
@@ -594,6 +723,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return reg;
}
/// <summary>
/// Classifies a failed notification registration. A status proving the device ANSWERED (symbol not
/// found, invalid access, PLC not ready, …) is a per-tag refusal the driver isolates without a health
/// write; a comms-class status (<c>BadCommunicationError</c> / <c>BadTimeout</c>) stays an ordinary
/// exception — a comms fault the driver degrades on and retries.
/// </summary>
internal static Exception RegistrationFailure(string symbolPath, string step, uint status) =>
status is TwinCATStatusMapper.BadCommunicationError or TwinCATStatusMapper.BadTimeout
? new InvalidOperationException($"{step} for {symbolPath} failed with status 0x{status:X8}")
: new TwinCATNotificationRefusedException(symbolPath, $"{step} failed with status 0x{status:X8}", status);
/// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
@@ -618,15 +758,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
if (!_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)))
Interlocked.Increment(ref _droppedNotifications); // queue full (DropWrite): count, never block
}
private long _droppedNotifications;
/// <inheritdoc />
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotifications);
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
// A value queued before its registration was deleted is purged here rather than delivered
// after the unsubscribe.
if (pending.Registration.IsDeleted) continue;
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
@@ -877,9 +1026,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary>
public void Dispose()
{
IsDeleted = true;
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
}
/// <summary>Gets a value indicating whether the registration was disposed; queued values for it are dropped.</summary>
public bool IsDeleted { get => Volatile.Read(ref _deleted); private set => Volatile.Write(ref _deleted, value); }
private bool _deleted;
}
private static Type MapToClrType(TwinCATDataType type) => type switch
@@ -916,14 +1071,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.UDInt => Convert.ToUInt32(value),
TwinCATDataType.LInt => Convert.ToInt64(value),
TwinCATDataType.ULInt => Convert.ToUInt64(value),
TwinCATDataType.Real => Convert.ToSingle(value),
TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.Real => ToSingleInRange(value),
TwinCATDataType.LReal => ToDoubleInRange(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
};
// Convert.ToSingle(1e300) is +Infinity, and Convert.ToDouble("1e400") too: writing that to the PLC
// would store a value nobody wrote. A finite source that overflows the target is out of range; an
// explicit infinity (or NaN) passes through as the caller's own value.
private static float ToSingleInRange(object? value)
{
var f = Convert.ToSingle(value, System.Globalization.CultureInfo.InvariantCulture);
if (float.IsInfinity(f) && !IsExplicitInfinity(value))
throw new OverflowException($"{value} is outside the REAL range (±{float.MaxValue:R})");
return f;
}
private static double ToDoubleInRange(object? value)
{
var d = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
if (double.IsInfinity(d) && !IsExplicitInfinity(value))
throw new OverflowException($"{value} is outside the LREAL range");
return d;
}
// An explicit infinity — a float/double infinity, or a string that SPELLS one ("Infinity", "-inf", "∞")
// — is the caller's own value and passes; a finite number that overflows ("1e400") does not.
private static bool IsExplicitInfinity(object? value) => value switch
{
float f => float.IsInfinity(f),
double d => double.IsInfinity(d),
string s => s.Contains("inf", StringComparison.OrdinalIgnoreCase) || s.Contains('∞'),
_ => false,
};
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
{
short s => (s & (1 << bit)) != 0,
@@ -102,6 +102,12 @@ public interface ITwinCATClient : IDisposable
/// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary>
uint LastSumFallbackCode => 0;
/// <summary>
/// Native notifications this client dropped because its bounded dispatch queue was full (the router
/// thread never blocks on a slow consumer). The driver warns when it grows. Default 0.
/// </summary>
long DroppedNotificationCount => 0;
/// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
@@ -119,6 +125,39 @@ public interface ITwinCATClient : IDisposable
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Writes an array tag: <paramref name="elements"/> (already converted to the declared element type by
/// <see cref="TwinCATArrayCodec.ToWriteElements"/>) as raw bytes at the layout planned from the RESOLVED
/// PLC array (<see cref="TwinCATTypeGuard.CheckArray"/>) and encoded by
/// <see cref="TwinCATArrayCodec.Encode"/> — the exact inverse of the array read, so an array tag writes
/// what it reads. Fewer elements than the PLC array writes its leading sub-range.
/// </summary>
/// <param name="symbolPath">The ADS symbol path of the array.</param>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements (the authored <c>ArrayLength</c> of them).</param>
/// <param name="cancellationToken">Cancellation token for the write.</param>
/// <returns>The mapped OPC UA status: Good; <c>BadTypeMismatch</c> when the declared element type /
/// length cannot address the PLC array; the ADS write's status otherwise.</returns>
/// <exception cref="OverflowException">A string element does not fit the PLC element's width.</exception>
Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken cancellationToken);
/// <summary>
/// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag
/// (<c>MAIN.Flags.3</c>; ADS has no symbol for <c>WORD.N</c>). A read-modify-write of the parent's raw
/// bytes at the parent's OWN resolved width (<see cref="TwinCATBitAccess.ApplyBitWrite"/>), so a 16-bit
/// parent is never written as 32 bits and a signed parent's sign bit is just a bit. The caller
/// serialises concurrent writers to the same parent; the PLC program writing the word between the
/// read and the write is inherent to RMW.
/// </summary>
/// <param name="parentPath">The parent word's ADS symbol path (the bit path without its <c>.N</c>).</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
/// <param name="value">The bit value to write.</param>
/// <param name="cancellationToken">Cancellation token for the read and the write.</param>
/// <returns>The mapped OPC UA status: Good; the parent read's status; <c>BadOutOfRange</c> for a bit
/// beyond the parent; <c>BadTypeMismatch</c> for a parent that is not an integer word.</returns>
Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
@@ -19,6 +19,84 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// </remarks>
internal static class TwinCATArrayCodec
{
/// <summary>
/// Converts a client-supplied array value into exactly <paramref name="count"/> elements of the
/// declared type, each converted the way a scalar write converts (<see cref="AdsTwinCATClient.ConvertForWrite"/>
/// — so TIME/DATE elements become their encoded <c>uint</c>). Any CLR collection is accepted and the
/// element type is CONVERTED, not exact-matched: ScadaBridge writes an <c>int[]</c> to an Int16 node.
/// </summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="value">The written value — an array or list.</param>
/// <param name="count">The tag's authored <c>ArrayLength</c>.</param>
/// <returns>The converted elements, in order.</returns>
/// <exception cref="InvalidCastException">The value is not a collection (a scalar, a string, null), or
/// an element cannot convert.</exception>
/// <exception cref="OverflowException">The element count differs from <paramref name="count"/>, or an
/// element is outside the declared type's range.</exception>
public static object[] ToWriteElements(TwinCATDataType declared, object? value, int count)
{
if (value is null or string || value is not System.Collections.IEnumerable items)
throw new InvalidCastException(
$"an array tag of {count} {declared} takes an array value, not {value?.GetType().Name ?? "null"}");
var elements = new List<object>(count);
foreach (var item in items) elements.Add(AdsTwinCATClient.ConvertForWrite(declared, item));
if (elements.Count != count)
throw new OverflowException($"wrote {elements.Count} elements to an array tag of arrayLength {count}");
return [.. elements];
}
/// <summary>
/// Encodes converted elements (<see cref="ToWriteElements"/>) into the PLC's raw bytes at the planned
/// layout — the exact inverse of <see cref="Decode"/>: little-endian at the element stride, a
/// STRING / WSTRING element encoded by <see cref="TwinCATStringCodec"/> within its fixed stride.
/// </summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="elements">The converted elements; exactly <see cref="TwinCATArrayLayout.ElementCount"/>.</param>
/// <param name="layout">The layout planned from the PLC array (<see cref="TwinCATTypeGuard.CheckArray"/>).</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data (else Windows-1252).</param>
/// <returns>Exactly <see cref="TwinCATArrayLayout.ByteLength"/> bytes.</returns>
/// <exception cref="ArgumentException">The element count differs from the layout.</exception>
/// <exception cref="OverflowException">A string element does not fit its stride.</exception>
public static byte[] Encode(TwinCATDataType declared, IReadOnlyList<object> elements, TwinCATArrayLayout layout, bool utf8)
{
if (layout.Stride <= 0 || elements.Count != layout.ElementCount)
throw new ArgumentException(
$"{elements.Count} elements do not match a layout of {layout.ElementCount} × {layout.Stride} B", nameof(elements));
var bytes = new byte[layout.ByteLength];
var stride = layout.Stride;
for (var i = 0; i < elements.Count; i++)
{
var slot = bytes.AsSpan(i * stride, stride);
var e = elements[i];
switch (declared)
{
case TwinCATDataType.Bool: slot[0] = Convert.ToBoolean(e) ? (byte)1 : (byte)0; break;
case TwinCATDataType.SInt: slot[0] = unchecked((byte)Convert.ToSByte(e)); break;
case TwinCATDataType.USInt: slot[0] = Convert.ToByte(e); break;
case TwinCATDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(slot, Convert.ToInt16(e)); break;
case TwinCATDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(slot, Convert.ToUInt16(e)); break;
case TwinCATDataType.DInt: BinaryPrimitives.WriteInt32LittleEndian(slot, Convert.ToInt32(e)); break;
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.TimeOfDay
or TwinCATDataType.Date or TwinCATDataType.DateTime:
BinaryPrimitives.WriteUInt32LittleEndian(slot, Convert.ToUInt32(e)); break;
case TwinCATDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(slot, Convert.ToInt64(e)); break;
case TwinCATDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(slot, Convert.ToUInt64(e)); break;
case TwinCATDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(slot, Convert.ToSingle(e)); break;
case TwinCATDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(slot, Convert.ToDouble(e)); break;
case TwinCATDataType.String:
TwinCATStringCodec.EncodeString(Convert.ToString(e) ?? string.Empty, stride, utf8).CopyTo(slot);
break;
case TwinCATDataType.WString:
TwinCATStringCodec.EncodeWString(Convert.ToString(e) ?? string.Empty, stride).CopyTo(slot);
break;
default:
throw new NotSupportedException($"TwinCATDataType {declared} has no array element encoding");
}
}
return bytes;
}
/// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param>
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
}
/// <summary>
/// The modify step of a bit write's read-modify-write: checks the bit against the parent
/// (<see cref="CheckParent"/>) and returns the parent's bytes — exactly <c>parent.ByteSize</c> of them —
/// with one bit set or cleared. Works on raw bytes, so the parent's width and signedness play no part.
/// </summary>
/// <param name="parent">The parent symbol's resolved shape.</param>
/// <param name="current">The parent's current bytes as read from the PLC.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="updated">The bytes to write back when the result is Good; otherwise empty.</param>
/// <returns>Good; <c>BadOutOfRange</c> for a bit beyond the parent; <c>BadTypeMismatch</c> for a parent
/// that is not an integer word; <c>BadCommunicationError</c> for a read shorter than the parent
/// (writing it back would zero the missing bytes).</returns>
public static uint ApplyBitWrite(TwinCATPlcSymbolShape parent, ReadOnlySpan<byte> current, int bit, bool value,
out byte[] updated)
{
updated = [];
if (CheckParent(bit, parent) is not null)
return parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat
? TwinCATStatusMapper.BadTypeMismatch
: TwinCATStatusMapper.BadOutOfRange;
if (current.Length < parent.ByteSize) return TwinCATStatusMapper.BadCommunicationError;
var bytes = current[..parent.ByteSize].ToArray();
var mask = (byte)(1 << (bit % 8));
bytes[bit / 8] = value ? (byte)(bytes[bit / 8] | mask) : (byte)(bytes[bit / 8] & ~mask);
updated = bytes;
return TwinCATStatusMapper.Good;
}
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
/// <param name="parent">The parent word's raw bytes.</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,57 @@ internal static class TwinCATStringCodec
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
}
// Strict encoders for the WRITE side: a character the target encoding cannot hold is refused rather
// than silently replaced with '?' (the replacement would be written to the PLC as if it were the value).
private static readonly Encoding StrictWindows1252 = CreateStrict(Windows1252.CodePage);
private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly Encoding StrictUtf16 = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true);
private static Encoding CreateStrict(int codePage) =>
Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ReplacementFallback);
/// <summary>
/// Encodes a value into an IEC <c>STRING(n)</c> buffer of the PLC's declared width (n+1 bytes): the
/// inverse of <see cref="DecodeString"/> — Windows-1252 unless <paramref name="utf8"/>, NUL-terminated
/// and NUL-padded so no stale tail of a longer earlier value survives.
/// </summary>
/// <param name="value">The value to write.</param>
/// <param name="byteWidth">The PLC symbol's byte width (n+1 for a <c>STRING(n)</c>).</param>
/// <param name="utf8">True when the target reports UTF-8 string data.</param>
/// <returns>Exactly <paramref name="byteWidth"/> bytes.</returns>
/// <exception cref="OverflowException">The value does not fit <c>STRING(n)</c>, or holds a character the
/// target encoding cannot represent.</exception>
public static byte[] EncodeString(string value, int byteWidth, bool utf8) =>
Encode(value, byteWidth, utf8 ? StrictUtf8 : StrictWindows1252, terminatorBytes: 1, "STRING");
/// <summary>
/// Encodes a value into an IEC <c>WSTRING(n)</c> buffer (UTF-16LE, 2(n+1) bytes): the inverse of
/// <see cref="DecodeWString"/>, terminated by an aligned <c>U+0000</c> and zero-padded.
/// </summary>
/// <param name="value">The value to write.</param>
/// <param name="byteWidth">The PLC symbol's byte width.</param>
/// <returns>Exactly <paramref name="byteWidth"/> bytes.</returns>
/// <exception cref="OverflowException">The value does not fit <c>WSTRING(n)</c>.</exception>
public static byte[] EncodeWString(string value, int byteWidth) =>
Encode(value, byteWidth, StrictUtf16, terminatorBytes: 2, "WSTRING");
private static byte[] Encode(string value, int byteWidth, Encoding encoding, int terminatorBytes, string kind)
{
ArgumentNullException.ThrowIfNull(value);
byte[] encoded;
try { encoded = encoding.GetBytes(value); }
catch (ArgumentException ex) // EncoderFallbackException: a character the encoding cannot hold
{
throw new OverflowException($"'{value}' cannot be represented in the PLC's {kind} encoding: {ex.Message}", ex);
}
if (encoded.Length + terminatorBytes > byteWidth)
throw new OverflowException(
$"'{value}' is {encoded.Length} B but the PLC {kind} holds {Math.Max(0, byteWidth - terminatorBytes)} B");
var buffer = new byte[byteWidth];
encoded.CopyTo(buffer, 0);
return buffer;
}
/// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary>
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
/// <returns>The decoded value.</returns>
@@ -21,7 +21,7 @@
<div class="col-md-3">
<label class="form-label">Max concurrent reads</label>
<InputNumber @bind-Value="_form.MaxConcurrentReads" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential.</div>
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential (libplctag). The native transport always uses at least 64 — it packs concurrent reads into one request.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
@@ -15,8 +15,22 @@
</div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
</select></div>
@foreach (var v in Enum.GetValues<FocasDataType>())
{
@* String is only readable from the fixed tree (FocasTagDefinitionFactory.DescribeDataTypeMismatch):
offered there, and kept visible when a loaded blob already carries it so the select never lies. *@
if (v != FocasDataType.String || _m.DataType == FocasDataType.String
|| FocasTagDefinitionFactory.DescribeDataTypeMismatch(v, _m.Address) is null)
{
<option value="@v">@v</option>
}
}
</select>
@if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(_m.DataType, _m.Address) is { } typeError)
{
<div class="form-text text-danger small">@typeError</div>
}
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="focas-writable" disabled checked="false" />
@@ -50,7 +50,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValueItem.FullReference</c>
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
/// </para>
@@ -268,7 +268,16 @@ public sealed class ScriptAnalysisService
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
return type.GetMembers()
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
// ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on
// ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics
// (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. This applies to
// EVERY receiver, not only ctx: a struct or enum receiver now also offers the System.ValueType /
// System.Enum instance members it inherits (CompareTo, HasFlag, ...), all of which compile.
var inherited = new List<ISymbol>();
for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType)
inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic));
return type.GetMembers().Concat(inherited)
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
// NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable.
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModel
// ahead of the parse consult below and is never replaced by it.
if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address);
// The driver's own address/data-type rule (String only on FIXED: paths) — checked on the typed fields so a
// model built in the editor is judged too, not only a loaded blob the parse gate below can see.
if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(DataType, Address) is { } typeError) return typeError;
// Parse-gate backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
// inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored
// 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Binds the node-wide <c>Scripting</c> section into <see cref="ScriptingOptions"/> and clamps it
/// (<see cref="ScriptingOptions.Sanitized"/>). One place, so <c>Program.cs</c> and its unit test bind the same
/// way and a misspelled key cannot silently fall back to the compiled defaults unnoticed.
/// </summary>
public static class ScriptingOptionsBinding
{
/// <summary>Binds and clamps the <c>Scripting</c> section.</summary>
/// <param name="configuration">The host configuration.</param>
/// <param name="adjustments">One line per clamped value, for the caller to log as a Warning.</param>
/// <returns>The clamped options; the compiled defaults when the section is absent.</returns>
public static ScriptingOptions Bind(IConfiguration configuration, out IReadOnlyList<string> adjustments)
{
ArgumentNullException.ThrowIfNull(configuration);
var bound = configuration.GetSection(ScriptingOptions.SectionName).Get<ScriptingOptions>() ?? new ScriptingOptions();
return bound.Sanitized(out adjustments);
}
}
@@ -68,6 +68,8 @@ public static class DriverFactoryBootstrap
// The calc driver needs the root script logger so a script failure fans out onto the
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
// The node-wide Scripting section (bound + clamped in Program.cs); null in test graphs ⇒ defaults.
var scripting = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions>();
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
var secretResolver = sp.GetRequiredService<ISecretResolver>();
@@ -84,7 +86,7 @@ public static class DriverFactoryBootstrap
// failures; without this it writes to NullLogger in production.
ConfigureAbTransportLogging(sp);
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
scriptRoot);
scriptRoot, scripting);
return registry;
});
services.AddSingleton<IDriverFactory>(sp =>
@@ -209,11 +211,12 @@ public static class DriverFactoryBootstrap
ISecretResolver secretResolver,
IDriverPrimaryRoleSource primaryRoleSource,
Driver.AbCip.AbCipTransport abCipDefaultTransport,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
+13 -1
View File
@@ -365,10 +365,22 @@ if (hasDriver)
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
// scripts at runtime.
// Node-wide script run-guard knobs (Scripting:MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts /
// StuckOrphanQuarantineFactor). Bound once and clamped into sane ranges here, so an absurd value is a startup
// Warning rather than an OverflowException inside a script run. The one instance is shared by every script
// seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via
// WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap).
var scriptingOptions = ZB.MOM.WW.OtOpcUa.Host.Configuration.ScriptingOptionsBinding.Bind(
builder.Configuration, out var scriptingAdjustments);
foreach (var adjustment in scriptingAdjustments)
Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment);
builder.Services.AddSingleton(scriptingOptions);
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
new RoslynVirtualTagEvaluator(
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
sp.GetRequiredService<ScriptRootLogger>()));
sp.GetRequiredService<ScriptRootLogger>(),
scripting: sp.GetRequiredService<ScriptingOptions>()));
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// and the ONLY value ingress from driver children (<see cref="DriverInstanceActor"/> wraps even a
/// single data change in an <c>AttributeValueBatchPublished</c>). <see cref="FanCommsLossToDependencyMux"/>
/// fabricates a batch of the same shape straight to the dependency mux, so the host neither receives nor
/// sends the per-value <c>AttributeValuePublished</c> any more.
/// sends a per-value message any more (the per-value record is deleted).
/// </summary>
public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedStash
{
@@ -202,6 +202,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly IHistoryWriter _historyWriter;
private readonly IActorRef? _virtualTagHostOverride;
private readonly ScriptRootLogger? _scriptRootLogger;
/// <summary>Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults.</summary>
private readonly ScriptingOptions? _scripting;
/// <summary>
/// Driver children this host has stopped (<see cref="StopChild"/>, an operator Restart) and is watching
/// until they terminate. A value burst from one of them is DROPPED in <see cref="ForwardBatchToMux"/>:
/// <c>Context.Stop</c> is asynchronous, so the old child keeps publishing for the rest of the apply
/// handler and its own stop, and forwarding those bursts after <see cref="EvictRetainedValues"/> would
/// re-retain a removed driver's last Good in the dependency mux indefinitely. Akka delivers
/// <see cref="Terminated"/> after the last message the watched child sent here, so removing the entry
/// there bounds the set without ever dropping a live incarnation's values.
/// </summary>
private readonly HashSet<IActorRef> _retiredChildren = new();
private readonly IActorRef? _scriptedAlarmHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -264,6 +278,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// from <see cref="PushDesiredSubscriptions"/>.</summary>
private IActorRef? _scriptedAlarmHost;
/// <summary>The scripted-alarm plans last sent to <see cref="_scriptedAlarmHost"/>, re-sent when it reports a
/// supervision restart (<see cref="ScriptedAlarmHostActor.ScriptedAlarmHostRestarted"/>). Null until the
/// first apply.</summary>
private IReadOnlyList<EquipmentScriptedAlarmPlan>? _lastScriptedAlarmPlans;
private RevisionHash? _currentRevision;
/// <summary>
@@ -641,8 +660,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
/// production (the real host is spawned).</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
/// unspawned — the graceful dev/None-deployment path.</param>
@@ -694,6 +711,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// capture to a mesh-partner Primary the cluster failure detector reports unreachable.</param>
/// <param name="historyUpdateRoleView">Where the OPC UA HistoryUpdate verdict is published for the node manager's
/// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager.</param>
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
/// engine. Null ⇒ the compiled defaults.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
@@ -707,7 +726,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
IVirtualTagEvaluator? virtualTagEvaluator = null,
IHistoryWriter? historyWriter = null,
IActorRef? virtualTagHostOverride = null,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRootLogger = null,
IActorRef? scriptedAlarmHostOverride = null,
IDriverCapabilityInvokerFactory? invokerFactory = null,
@@ -728,7 +746,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
bool captureWhilePrimaryUnreachable = true,
IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
IHistoryUpdateRoleView? historyUpdateRoleView = null,
ScriptingOptions? scripting = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -737,11 +756,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView,
scripting));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -760,8 +780,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
/// <param name="loggerFactory">Retained for constructor-signature stability; unused since the
/// <c>EfAlarmConditionStateStore</c> fallback that consumed it was retired. Defaults to null.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host; when null the host is left unspawned.</param>
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
@@ -830,6 +848,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> — the data-plane verdict, but closed until the first
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
/// redundancy manager.</param>
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
/// engine. Null ⇒ the compiled defaults.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode,
@@ -842,7 +862,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
IVirtualTagEvaluator? virtualTagEvaluator = null,
IHistoryWriter? historyWriter = null,
IActorRef? virtualTagHostOverride = null,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRootLogger = null,
IActorRef? scriptedAlarmHostOverride = null,
IDriverCapabilityInvokerFactory? invokerFactory = null,
@@ -863,8 +882,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
bool captureWhilePrimaryUnreachable = true,
IHistoryUpdateRoleView? historyUpdateRoleView = null)
IHistoryUpdateRoleView? historyUpdateRoleView = null,
ScriptingOptions? scripting = null)
{
_scripting = scripting;
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
_historyUpdateRoleView = historyUpdateRoleView;
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
@@ -1146,8 +1167,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
/// <see cref="DependencyMuxTagUpstreamSource"/> + the wired <see cref="IAlarmStateStore"/> (the
/// replicated <c>LocalDbAlarmConditionStateStore</c> on a driver-role node); when no store was
/// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its
/// PostStop, so it stops with the driver host.
/// wired the host is skipped. The host is spawned through the factory <c>Props</c>, so EACH
/// incarnation (a supervision restart included) builds, owns and disposes its own engine; it stops
/// with the driver host.
/// </summary>
private void SpawnScriptedAlarmHost()
{
@@ -1178,13 +1200,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// Factory Props: every incarnation of the host (a supervision restart included) builds and owns a fresh
// (upstream, engine) pair, so a restart never reuses an engine the previous incarnation disposed or left
// half-loaded. The closure captures locals only; it runs in the child's constructor.
var store = _alarmStateStore;
var upstream = new DependencyMuxTagUpstreamSource();
var engine = new ScriptedAlarmEngine(
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
var scriptLogger = _scriptRootLogger.Logger;
var scripting = _scripting;
_scriptedAlarmHost = Context.ActorOf(
ScriptedAlarmHostActor.Props(
_opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub),
_opcUaPublishActor, _dependencyMux,
() =>
{
var upstream = new DependencyMuxTagUpstreamSource();
var engine = new ScriptedAlarmEngine(
upstream, store, new ScriptLoggerFactory(scriptLogger), scriptLogger, scripting: scripting);
return (upstream, engine);
},
_localNode, telemetryHub: _telemetryHub),
"scripted-alarm-host");
}
@@ -1500,6 +1532,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<ApplyWedged>(HandleApplyWedged);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<Terminated>(OnRetiredChildTerminated);
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
@@ -1562,6 +1596,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<ApplyWedged>(HandleApplyWedged);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<Terminated>(OnRetiredChildTerminated);
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
@@ -1606,6 +1642,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// A burst from an incarnation this host already stopped: forwarding it would undo the SourceRemoved
// eviction (see _retiredChildren). Its ConnectivityChanged is NOT filtered — the respawn path relies on
// that Bad fan to cover the gap.
if (_retiredChildren.Contains(Sender))
{
_log.Debug("DriverHost {Node}: dropped a {Count}-item value burst from a stopped incarnation of {Id}",
_localNode, msg.Items.Count, msg.DriverInstanceId);
return;
}
// One envelope per burst; the mux filters per item (it registered a batch receive).
_dependencyMux?.Tell(msg);
@@ -2501,6 +2547,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<DriverInstanceActor.AttributeValueBatchPublished>(m =>
_log.Debug("DriverHost {Node}: dropping a {Count}-item value burst from {Id} while Stale (no address space)",
_localNode, m.Items.Count, m.DriverInstanceId));
Receive<Terminated>(OnRetiredChildTerminated);
Receive<ScriptedAlarmHostActor.ScriptedAlarmHostRestarted>(_ => HandleScriptedAlarmHostRestarted());
Receive<DriverInstanceActor.AttributeAlarmPublished>(m =>
_log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale",
_localNode, m.Args.ConditionId, m.DriverInstanceId));
@@ -3652,6 +3700,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers
// both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery
// path deliberately does not, matching driver + VirtualTag recovery.
_lastScriptedAlarmPlans = composition.EquipmentScriptedAlarms;
_scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms));
if (composition.EquipmentScriptedAlarms.Count > 0)
{
@@ -3816,8 +3865,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
StopDependencyAdapter(driverInstanceId);
RetireChild(entry.Actor);
Context.Stop(entry.Actor);
_children.Remove(driverInstanceId);
EvictRetainedValues(driverInstanceId);
// The instance left the plan (StopChild is only reached via plan.ToStop), so its cached
// Health/Resilience snapshots must not replay to new telemetry subscribers. Release the shared
// resilience state FIRST: the resilience publisher re-emits the tracker's snapshots every tick, so
@@ -3836,6 +3887,56 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
}
/// <summary>
/// Tells the dependency mux to drop every retained value this driver instance produced, so a stopped
/// driver's last Good is never replayed to a later replay-opting subscriber as if it were current. Sent
/// wherever a driver OBJECT ends: a deploy removal, the stop half of a config-change respawn
/// (<see cref="StopChild"/>) and an operator Restart (a new driver object). Deliberately NOT sent on a
/// supervision restart (<see cref="HandleReprovisionRequested"/>): that re-uses the same driver, whose last value is
/// still its current one. Sent before any respawn, on the same host→mux pair the new incarnation's
/// values travel, so the eviction is ordered ahead of them.
/// </summary>
/// <remarks>
/// The stopped child keeps publishing until it actually terminates, and for a REMOVED driver nothing
/// would ever overwrite a value re-retained after this eviction (it never publishes again, and its
/// PostStop Bad fan resolves no refs once the apply has rebuilt the index without it). So the caller
/// also <see cref="RetireChild"/>s it, and <see cref="ForwardBatchToMux"/> drops every burst from a
/// retired child.
/// </remarks>
/// <param name="driverInstanceId">The driver instance that ended.</param>
private void EvictRetainedValues(string driverInstanceId) =>
_dependencyMux?.Tell(new DependencyMuxActor.SourceRemoved(driverInstanceId));
/// <summary>Marks a child this host is about to stop as retired and watches it until it terminates.</summary>
/// <param name="child">The child being stopped.</param>
private void RetireChild(IActorRef child)
{
_retiredChildren.Add(child);
Context.Watch(child);
}
/// <summary>
/// The scripted-alarm host was restarted by supervision and has an empty engine: re-send the last plans so
/// its inputs and operator commands work again without waiting for the next deploy. Only the CURRENT host's
/// signal is honoured.
/// </summary>
private void HandleScriptedAlarmHostRestarted()
{
if (_scriptedAlarmHost is null || !Sender.Equals(_scriptedAlarmHost)) return;
if (_lastScriptedAlarmPlans is null)
{
_log.Debug("DriverHost {Node}: ScriptedAlarm host restarted before any apply; nothing to re-send", _localNode);
return;
}
_log.Warning("DriverHost {Node}: ScriptedAlarm host was restarted by supervision; re-applying {Count} scripted alarm(s)",
_localNode, _lastScriptedAlarmPlans.Count);
_scriptedAlarmHost.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(_lastScriptedAlarmPlans));
}
/// <summary>A retired child has terminated; nothing more can arrive from it.</summary>
/// <param name="msg">The termination notice.</param>
private void OnRetiredChildTerminated(Terminated msg) => _retiredChildren.Remove(msg.ActorRef);
private static string ActorNameFor(string driverInstanceId, long generation)
{
// Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively. The monotonic
@@ -3881,8 +3982,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
StopDependencyAdapter(msg.DriverInstanceId);
RetireChild(entry.Actor);
Context.Stop(entry.Actor);
_children.Remove(msg.DriverInstanceId);
// An operator Restart builds a NEW driver object, so the old one's retained values are evicted like a
// removal's; the respawned driver republishes its own.
EvictRetainedValues(msg.DriverInstanceId);
// The old incarnation's health must not read as the new one's until it publishes its own.
_healthPublisher.Forget(msg.DriverInstanceId);
@@ -108,21 +108,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// state — a no-op when already Reconnecting or Connecting.
/// </summary>
public sealed record ForceReconnect;
/// <summary>One driver-published value. <b>No production sender remains</b> — the subscription path
/// batches every data change into an <see cref="AttributeValueBatchPublished"/>, and the comms-loss fan
/// (<c>DriverHostActor.FanCommsLossToDependencyMux</c>), which was the last sender to fabricate this
/// message, now fabricates a batch too. The dependency mux still accepts it for tests and direct senders.
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
/// — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para>
/// <para>A struct deliberately: an <c>AttributeValueItem[]</c> then holds its items inline, so a burst of
/// N values costs one array allocation instead of one array plus N boxes. Indexed access through
/// <see cref="IReadOnlyList{T}"/> does not box.</para></summary>
public readonly record struct AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>Batched form of <see cref="AttributeValuePublished"/> — every changed attribute of ONE
/// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA
/// <summary>The ONLY value message a driver child sends: every changed attribute of ONE driver production
/// burst in one message (a single data change is a one-item batch). The parent fans the whole batch to the mux and the OPC UA
/// publish path in one hop each. Status codes ride verbatim.</summary>
public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList<AttributeValueItem> Items);
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
@@ -132,7 +127,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private sealed record DataChangeBatchForward(IReadOnlyList<DataChangeItem> Items);
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValueBatchPublished"/>.</summary>
public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args);
private sealed record NativeAlarmRaised(AlarmEventArgs Args);
/// <summary>Self-sent on Connected entry / when alarm refs are (re)pushed, to establish the native-alarm
@@ -86,6 +86,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// <param name="Plans">The desired Equipment-namespace scripted-alarm plans.</param>
public sealed record ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans);
/// <summary>
/// Told to the parent from <see cref="PostRestart"/>. A supervision restart is otherwise invisible to the
/// parent, and the new incarnation has an EMPTY engine (factory Props) and no mux interest, so until the
/// next <see cref="ApplyScriptedAlarms"/> its inputs stop and every operator command is dropped as not
/// owned. The parent (<c>DriverHostActor</c>) answers by re-sending the last plans it applied, mirroring
/// how a driver child's <c>ReprovisionRequested</c> is answered.
/// </summary>
public sealed class ScriptedAlarmHostRestarted
{
/// <summary>The single instance.</summary>
public static readonly ScriptedAlarmHostRestarted Instance = new();
private ScriptedAlarmHostRestarted() { }
}
/// <summary>Marshals an engine emission off the engine's worker thread onto the actor thread.
/// Carries the <see cref="ScriptedAlarmEvent"/> the engine raised on <c>OnEvent</c>.</summary>
private sealed record EngineEmission(ScriptedAlarmEvent Event);
@@ -384,9 +399,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the
// engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor),
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
// ReplayRetained: the mux replays the last value it saw for each ref, so a fresh engine (a
// supervision restart builds one around an EMPTY upstream) converges immediately instead of
// waiting for every input to change again.
// ReplayRetained: the mux replays the last value it saw for each ref, so a freshly loaded engine
// converges immediately instead of waiting for every input to change again. That includes the engine
// a supervision restart builds around an EMPTY upstream, but only once the parent has re-sent
// ApplyScriptedAlarms in answer to ScriptedAlarmHostRestarted (PostRestart); nothing reloads it before.
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self, ReplayRetained: true));
// Prune the upstream's retained snapshots to the loaded set (R02:L3): a path no alarm references
// any more will never be pushed again, so its last value is dead weight for the process lifetime.
@@ -791,6 +807,15 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
base.PreRestart(reason, message);
}
/// <inheritdoc />
/// <remarks>Tells the parent <see cref="ScriptedAlarmHostRestarted"/> so it re-sends the last
/// <see cref="ApplyScriptedAlarms"/>: the new incarnation has nothing loaded and no mux interest.</remarks>
protected override void PostRestart(Exception reason)
{
base.PostRestart(reason);
Context.Parent.Tell(ScriptedAlarmHostRestarted.Instance);
}
/// <inheritdoc />
protected override void PostStop()
{
@@ -609,7 +609,6 @@ public static class ServiceCollectionExtensions
healthPublisher: healthPublisher,
virtualTagEvaluator: virtualTagEvaluator,
historyWriter: historyWriter,
loggerFactory: loggerFactory,
scriptRootLogger: scriptRootLogger,
invokerFactory: invokerFactory,
deploymentArtifactCache: deploymentArtifactCache,
@@ -638,7 +637,10 @@ public static class ServiceCollectionExtensions
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
resolver.GetService<IConfiguration>(), roleInfo, replicationPeerHost),
// The HistoryUpdate verdict (closed until the first redundancy snapshot).
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>()),
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>(),
// The node-wide Scripting section (bound + clamped in Program.cs) for the scripted-alarm
// engine. Unbound in test graphs ⇒ null ⇒ the compiled defaults.
scripting: resolver.GetService<ScriptingOptions>()),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);
@@ -15,11 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role
/// node). The ONLY driver ingress is the batched
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — one envelope per driver
/// production burst, which the mux filters per item. The per-value
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> receive has <b>no production sender left
/// at all</b>: the comms-loss fan (<c>DriverHostActor.FanCommsLossToDependencyMux</c>) was its last
/// one and now fabricates an <c>AttributeValueBatchPublished</c> instead. The receive is kept for
/// tests and direct senders — treat it as dead in production.
/// production burst, which the mux filters per item. The comms-loss fan
/// (<c>DriverHostActor.FanCommsLossToDependencyMux</c>) fabricates one too. There is no per-value
/// ingress: a single value is a one-item batch (tests build one with <c>ValuePublishes.Single</c>).
///
/// <b>The mux emits ONLY batches</b>, so a subscriber's
/// batch receive is its complete hot path. Every subscriber keeps a per-value
@@ -52,11 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// <b>Source removal.</b> The mux records which driver instance last published each retained ref;
/// <see cref="SourceRemoved"/> evicts every value that driver produced, so a removed driver's last
/// Good is never replayed as current. A comms-loss fan (a Bad batch) already overwrites the retained
/// value with Bad. ⚠️ As of fix round 1 no production actor sends <see cref="SourceRemoved"/> yet —
/// <c>DriverHostActor</c> (outside the scripting lane) must Tell it when it stops / removes a driver
/// child. Until then a removed driver's last value can still be replayed to a replay-opting
/// subscriber that re-registers for that ref while another replay-opting subscriber holds it, or
/// within the grace window.
/// value with Bad. <c>DriverHostActor</c> sends <see cref="SourceRemoved"/> wherever a driver object
/// ends (a deploy removal, the stop half of a config-change respawn, an operator Restart), and never
/// on a supervision restart, which re-uses the same driver.
/// </para>
/// </summary>
public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
@@ -154,7 +150,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
Receive<UnregisterInterest>(msg => RemoveSubscriber(msg.Subscriber));
Receive<SourceRemoved>(OnSourceRemoved);
Receive<SweepReleased>(_ => OnSweep());
Receive<DriverInstanceActor.AttributeValuePublished>(OnAttributeValuePublished);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(OnAttributeValueBatchPublished);
Receive<Terminated>(msg => RemoveSubscriber(msg.ActorRef));
}
@@ -265,30 +260,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers
_log.Debug("DependencyMux: source {Driver} removed — evicted {Count} retained value(s)", msg.DriverInstanceId, evicted);
}
private void OnAttributeValuePublished(DriverInstanceActor.AttributeValuePublished msg)
{
if (!_byRef.TryGetValue(msg.FullReference, out var entry))
{
// Nothing is registered for (or retaining) this ref — drop. Common in normal operation; the address
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
return;
}
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode);
if (entry.Retains)
{
entry.Last = dep;
entry.LastSource = msg.DriverInstanceId;
}
foreach (var sub in entry.Subscribers)
{
// A single-item batch, so the mux emits exactly ONE message shape and every subscriber needs
// only the batch receive on its hot path. One list per subscriber: batch ownership transfers
// to the receiver, so a shared list must never cross mailboxes. Nothing in production reaches
// this path (see the class doc) — the extra allocation is noise on a test-only seam.
sub.Tell(new VirtualTagActor.DependencyValueBatch(new[] { dep }));
}
}
private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// Nothing registered and nothing retained — skip the whole burst rather than probing an empty map per item.
@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// <c>ctx.CancellationToken</c> — the run token, visible to scripts. <see cref="ScriptRunGuard"/> already
/// cancels the token it hands <see cref="ScriptEvaluator{TContext, TResult}.RunAsync"/> when a run times out;
/// a script that polls it can now end its own abandoned run instead of burning a pool thread until it
/// returns. Cooperative only: a script that ignores it is still an orphan.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptCancellationTokenTests
{
/// <summary>Spins until cancelled, but ends by itself after 10 s so a broken implementation cannot pin a
/// pool thread past the test.</summary>
private const string CooperativeSpin =
"""
var end = System.DateTime.UtcNow.AddSeconds(10);
while (!ctx.CancellationToken.IsCancellationRequested && System.DateTime.UtcNow < end) { }
return ctx.CancellationToken.IsCancellationRequested ? -1 : 1;
""";
[Fact]
public async Task A_script_sees_an_uncancelled_token_on_a_normal_run()
{
using var eval = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"return ctx.CancellationToken.CanBeCanceled && !ctx.CancellationToken.IsCancellationRequested;");
using var cts = new CancellationTokenSource();
(await eval.RunAsync(new FakeScriptContext(), cts.Token)).ShouldBeTrue();
}
[Fact]
public async Task Outside_a_run_the_token_is_None()
{
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile("return 1;");
var ctx = new FakeScriptContext();
(await eval.RunAsync(ctx)).ShouldBe(1);
ctx.CancellationToken.ShouldBe(CancellationToken.None, "the token is scoped to the run, not left on the context");
}
[Fact]
public async Task A_timed_out_run_whose_script_polls_the_token_ends_its_own_orphan()
{
var guard = new ScriptRunGuard();
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile(CooperativeSpin);
var ct = TestContext.Current.CancellationToken;
await Should.ThrowAsync<ScriptTimeoutException>(
() => guard.RunAsync("k", CooperativeSpin, eval, new FakeScriptContext(), TimeSpan.FromMilliseconds(100), ct));
// The guard cancelled the run token on timeout; the script saw it and returned, which frees the slot well
// inside its own 10 s fallback. Without ctx.CancellationToken the orphan would hold it for the full 10 s.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (guard.InFlightCount != 0 && DateTime.UtcNow < deadline)
await Task.Delay(20, ct);
guard.InFlightCount.ShouldBe(0, "the cooperative script ended its abandoned run");
// Positive control: the slot is free, so the next run is dispatched rather than refused. Same source
// identity on purpose: a different one would reset the key and dispatch even behind a live orphan.
using var fast = ScriptEvaluator<FakeScriptContext, int>.Compile("return 5;");
(await guard.RunAsync("k", CooperativeSpin, fast, new FakeScriptContext(), TimeSpan.FromSeconds(5), ct))
.ShouldBe(5);
}
}
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// <see cref="ScriptingOptions.Sanitized"/> — the bind-time clamp of the <c>Scripting</c> section's numeric
/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an
/// <see cref="OverflowException"/> inside a script run (<c>timeout * StuckOrphanQuarantineFactor</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptingOptionsTests
{
[Fact]
public void Defaults_pass_through_unchanged_with_no_adjustments()
{
var sanitized = new ScriptingOptions().Sanitized(out var adjustments);
adjustments.ShouldBeEmpty();
sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
}
[Theory]
[InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
[InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)]
[InlineData(-5, 0)]
[InlineData(double.NegativeInfinity, 0)]
[InlineData(0, 0)]
[InlineData(2.5, 2.5)]
public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected)
{
var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments);
sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected);
// Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning.
if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor)
adjustments.ShouldBeEmpty();
}
[Theory]
[InlineData(0, 1)]
[InlineData(-3, 1)]
[InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)]
[InlineData(5, 5)]
public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) =>
new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _)
.QuarantineAfterConsecutiveTimeouts.ShouldBe(expected);
[Theory]
[InlineData(-1L, 0L)]
[InlineData(0L, 0L)]
[InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)]
[InlineData(long.MaxValue, long.MaxValue)]
public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) =>
new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _)
.MaxAllocBytesPerRun.ShouldBe(expected);
[Fact]
public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key()
{
_ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments);
adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor");
}
[Fact]
public void Sanitized_returns_a_copy_and_leaves_the_source_untouched()
{
var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 };
var sanitized = source.Sanitized(out _);
sanitized.ShouldNotBeSameAs(source);
source.StuckOrphanQuarantineFactor.ShouldBe(1e300);
}
/// <summary>
/// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests),
/// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal
/// behind a young orphan is still a plain refusal rather than an <see cref="OverflowException"/>.
/// </summary>
[Fact]
public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal()
{
var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue });
const string spin =
"""
var end = System.DateTime.UtcNow.AddSeconds(3);
while (System.DateTime.UtcNow < end) { }
return 1;
""";
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile(spin);
var ct = TestContext.Current.CancellationToken;
var timeout = TimeSpan.FromMilliseconds(60);
await Should.ThrowAsync<ScriptTimeoutException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
// The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException.
await Should.ThrowAsync<ScriptRunRefusedException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
// A huge timeout times the clamped maximum factor must not overflow either.
await Should.ThrowAsync<ScriptRunRefusedException>(
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct));
}
}
@@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests
/// <summary>Invokes option validation without interval.</summary>
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
/// <summary>Invokes the address/data-type check.</summary>
public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type);
}
/// <summary>
/// #740 / R07:U8 — <c>--type String</c> on a PMC / PARAM / MACRO address is refused at the CLI boundary
/// (the driver would skip the tag and the read would print BadNodeIdUnknown); a fixed-tree path keeps it.
/// </summary>
[Theory]
[InlineData("R100", FocasDataType.String, false)]
[InlineData("MACRO:500", FocasDataType.String, false)]
[InlineData("PARAM:6711", FocasDataType.String, false)]
[InlineData("R100", FocasDataType.Byte, true)]
[InlineData("FIXED:Identity/SeriesNumber", FocasDataType.String, true)]
public void Validate_data_type_against_address(string address, FocasDataType type, bool ok)
{
var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
if (ok) Should.NotThrow(() => sut.InvokeValidateDataType(address, type));
else Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidateDataType(address, type))
.Message.ShouldContain("String");
}
/// <summary>Verifies that default options are accepted.</summary>
@@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
}
/// <summary>An array value splits on the separator and parses each element per --type (#739 U9 gate tooling).</summary>
[Fact]
public void ParseArrayValue_parses_each_element()
{
WriteCommand.ParseArrayValue("1, -2,3", TwinCATDataType.Int, 3, ",")
.ShouldBe(new object[] { (short)1, (short)-2, (short)3 });
WriteCommand.ParseArrayValue("a b|c", TwinCATDataType.String, 2, "|")
.ShouldBe(new object[] { "a b", "c" });
}
/// <summary>A count that differs from --array-length is refused before the PLC is touched.</summary>
[Fact]
public void ParseArrayValue_refuses_a_count_mismatch()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1,2", TwinCATDataType.Int, 3, ","));
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1", TwinCATDataType.Int, 0, ","));
}
}
@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Review 07 (Gitea #737) against a running <c>ab_server</c> on both transports: a controller refusal is a per-tag
/// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10).
/// Skipped when the fixture is unreachable (<see cref="AbServerTheoryAttribute"/>); the array write additionally
/// needs <c>AB_SERVER_ALLOW_WRITES=1</c> (<see cref="IntegrationWriteGate"/>).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipRefusalLiveTests
{
private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0";
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string what)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(50);
}
}
/// <summary>
/// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created
/// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "NoSuchProbeTag_xyz",
Interval = TimeSpan.FromMilliseconds(200),
Timeout = TimeSpan.FromSeconds(2),
},
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h10-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag");
await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure
var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and
/// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other
/// tag, trip the comms-loss watchdog against a reachable controller).
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport)
{
var device = Device(new AbServerFixture());
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt),
new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-h1-{transport}");
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
DateTime? last = null;
for (var i = 0; i < 3; i++)
{
var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous);
last = health.LastSuccessfulRead;
}
// Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip).
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// U10: a whole <c>int[]</c> written to the 16-element <c>TestArray</c> (DINT[16]) reads back element for element
/// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing.
/// </summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbServerTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task A_whole_array_write_round_trips(AbCipTransport transport)
{
IntegrationWriteGate.SkipUnlessWritesAllowed();
var device = Device(new AbServerFixture());
var seed = Random.Shared.Next(1_000, 1_000_000);
var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray();
AbCipDriver Make(string id) => new(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
Transport = transport,
}, $"drv-u10-{id}-{transport}");
var writer = Make("writer");
await writer.InitializeAsync("{}", CancellationToken.None);
try
{
var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None);
w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None);
bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
writer.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await writer.DisposeAsync();
}
var reader = Make("reader");
await reader.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await reader.ReadAsync(["Arr"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[0].Value.ShouldBe(values);
}
finally
{
await reader.DisposeAsync();
}
}
}
@@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
// every re-created runtime must fail too (a fresh fake would read as "all clear").
var recreations = 0;
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
// A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted.
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; };
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection;
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
await r.StopAsync();
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
@@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests
{
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
Interlocked.Increment(ref bad);
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created
};
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection;
await WaitFor(() => Volatile.Read(ref bad) >= 4);
// The link recovers and HAcked reads true again.
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
/// Operate-writable, but both runtimes encoded a SCALAR: an <c>int[]</c> threw InvalidCastException (BadTypeMismatch
/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
/// element 0 patched. The driver now encodes <c>count</c> elements at the element width, refuses a length mismatch
/// with <c>BadOutOfRange</c>, and converts element values (ScadaBridge writes <c>int[]</c> to INT arrays).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayWriteTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
AbCipDataType type, int count, int size = -1)
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-array-write", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
// ---- the codec ----
/// <summary>Each element is converted and laid out little-endian at the element width.</summary>
[Fact]
public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
{
AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
Should.Throw<OverflowException>(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
Should.Throw<NotSupportedException>(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
}
// ---- the driver ----
/// <summary>ScadaBridge's wire shape: an <c>int[]</c> to an INT[3] encodes 3 two-byte elements and writes once, Good.</summary>
[Fact]
public async Task An_int_array_to_an_INT_array_encodes_every_element()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var tag = factory.Tags["Arr"];
tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
tag.EncodedArrayCount.ShouldBe(3);
tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
tag.WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A list of a different length is BadOutOfRange; nothing is created, encoded or written.</summary>
/// <param name="length">The client's element count.</param>
[Theory]
[InlineData(2)]
[InlineData(4)]
public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.</summary>
[Fact]
public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync(
[new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags.ShouldNotContainKey("Arr");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An element that does not fit is BadOutOfRange with no health write and nothing sent.</summary>
[Fact]
public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>BOOL / STRING arrays are refused BadNotSupported before any runtime exists.</summary>
/// <param name="type">The array element type.</param>
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
{
var (drv, factory) = await BuildAsync(type, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
factory.Tags.ShouldNotContainKey("Arr");
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health
}
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
[Fact]
public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime: an <c>int[]</c> to an INT[3] goes out as ONE Write Tag of type
/// 0xC3 with element count 3 and the three 2-byte elements.
/// </summary>
[Fact]
public async Task A_native_array_write_is_one_write_of_count_elements()
{
await using var target = new FakeCipTarget();
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
}
finally
{
await drv.DisposeAsync();
}
}
}
@@ -0,0 +1,161 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H17: Logix allows a <c>.n</c> bit selector on SINT / INT / DINT (and LINT) hosts, and reads of
/// <c>Flags.3</c> on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its
/// <c>DecodeValue(DInt)</c> on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault
/// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipBitInWordWidthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-bit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades.</summary>
/// <param name="size">The parent's wire width.</param>
/// <param name="before">The parent's value before the write.</param>
/// <param name="bit">The bit written true.</param>
/// <param name="after">The value the parent must be written back with.</param>
[Theory]
[InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte
[InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode
[InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved
[InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode
[InlineData(4, 0x00000001, 3, 0x00000009)]
public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after)
{
var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A bit past the host's width is refused before anything is written, with no health write.</summary>
[Fact]
public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written()
{
var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A host whose width is no integer's is a type mismatch, refused before writing, no health write.</summary>
[Fact]
public async Task A_host_of_no_integer_width_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime against a fake CIP target whose <c>Flags</c> is an INT: the write
/// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved.
/// </summary>
[Fact]
public async Task A_native_bit_write_on_an_INT_host_writes_an_INT()
{
await using var target = new FakeCipTarget();
// Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success.
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80],
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT
write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element
write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3
write[^1].ShouldBe((byte)0x80);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await drv.DisposeAsync();
}
}
/// <summary>
/// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead
/// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health.
/// </summary>
[Fact]
public async Task A_failed_parent_read_follows_the_health_contract()
{
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection };
var before = drv.GetHealth();
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue();
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
}
/// <summary>M1 positive control: a refused parent read keeps the runtime, no health write.</summary>
[Fact]
public async Task A_refused_parent_read_keeps_the_runtime_and_health()
{
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed };
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,298 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDeviceRefusalTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public AbStatus? ReadThrows { get; set; }
public AbStatus? WriteThrows { get; set; }
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
}
public override async Task WriteAsync(CancellationToken cancellationToken)
{
await base.WriteAsync(cancellationToken);
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
}
}
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
Func<AbCipTagCreateParams, FakeAbCipTag> make)
{
var factory = new FakeAbCipTagFactory { Customise = make };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-refusal", factory);
return (drv, factory);
}
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
/// <param name="status">The transport status.</param>
/// <param name="refusal">Whether it is a device refusal.</param>
[Theory]
[InlineData(AbStatus.NotFound, true)]
[InlineData(AbStatus.OutOfBounds, true)]
[InlineData(AbStatus.NotAllowed, true)]
[InlineData(AbStatus.TooLarge, true)]
[InlineData(AbStatus.TooSmall, true)]
[InlineData(AbStatus.TypeMismatch, true)]
[InlineData(AbStatus.Unsupported, true)]
[InlineData(AbStatus.Ok, false)]
[InlineData(AbStatus.Timeout, false)]
[InlineData(AbStatus.BadConnection, false)]
[InlineData(AbStatus.BadReply, false)]
[InlineData(AbStatus.Partial, false)]
[InlineData(AbStatus.Abort, false)]
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
{
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
}
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
/// <param name="status">The refusal the controller answers with.</param>
/// <param name="expected">The OPC UA status the tag reads.</param>
[Theory]
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
{
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
for (var i = 0; i < 3; i++)
{
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
r[0].StatusCode.ShouldBe(expected);
}
var bad = factory.Tags["Arr[999]"];
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
bad.Disposed.ShouldBeFalse();
bad.ReadCount.ShouldBe(3);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
}
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
[Fact]
public async Task A_transport_failure_still_evicts_and_degrades()
{
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
[Fact]
public async Task A_refusal_status_word_keeps_the_runtime()
{
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Bad"], CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
/// </summary>
[Fact]
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
: new FakeAbCipTag(p) { Value = 42 });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[1].Value.ShouldBe(42);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
[Fact]
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-refusal", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's
/// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward
/// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn.
/// </summary>
[Fact]
public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn()
{
await using var target = new FakeCipTarget();
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req));
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var creates = 0;
var native = new NativeAbCipTagFactory(new DeviceSessionPool());
var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); });
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-l4-native", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
for (var n = 0; n < 5; n++)
(await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
Volatile.Read(ref creates).ShouldBe(1);
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await drv.DisposeAsync();
}
}
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
}
/// <summary>
/// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health
/// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the
/// device's own Unsupported refusal does advance it (M2).
/// </summary>
[Fact]
public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new LocalRefusingTag(p)
: new RefusingTag(p) { ReadThrows = AbStatus.Unsupported });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().LastSuccessfulRead.ShouldBe(before);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
}
/// <summary>#737 I2: a local refusal at create opens no backoff — a sibling cold create still connects.</summary>
[Fact]
public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered()
{
var (drv, _) = Build(p => p.TagName == "Arr[999]"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") }
: new FakeAbCipTag(p) { Value = 3 });
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>M2: a write the device refuses proves it answered — LastSuccessfulRead advances.</summary>
[Fact]
public async Task A_refused_write_advances_LastSuccessfulRead()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-refusal", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
await Task.Delay(20);
(await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadNotWritable);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
throw new AbCipLocalRefusalException("ErrorNotImplemented");
}
}
}
@@ -0,0 +1,239 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Lifecycle overlap the runtime can produce (review 07 H13/F10, Lane A): the final stop runs
/// <c>ShutdownAsync</c> then <c>DisposeAsync</c> under one bounded budget, so a slow shutdown is abandoned and
/// the dispose runs CONCURRENTLY with it; an abandoned <c>InitializeAsync</c> can overlap the next attempt's
/// shutdown. Device tables are build-and-swapped, so each teardown owns a disjoint set of device states.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A fake whose reads ignore cancellation for a while, so a teardown waits on the probe loop.</summary>
private sealed class SlowTag(AbCipTagCreateParams p, TimeSpan readDelay) : FakeAbCipTag(p)
{
private int _disposeCount;
public int DisposeCount => Volatile.Read(ref _disposeCount);
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
if (readDelay > TimeSpan.Zero) await Task.Delay(readDelay, CancellationToken.None);
}
public override void Dispose()
{
Interlocked.Increment(ref _disposeCount);
base.Dispose();
}
}
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(TimeSpan probeReadDelay)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new SlowTag(p, p.TagName == "ProbeTag" ? probeReadDelay : TimeSpan.Zero) { Value = 1 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTag",
Interval = TimeSpan.FromMilliseconds(10),
Timeout = TimeSpan.FromMilliseconds(200),
},
}, "abcip-lifecycle", factory);
return (drv, factory);
}
private static async Task WaitUntilAsync(Func<bool> condition, string what, int seconds = 5)
{
var deadline = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(5);
}
}
/// <summary>
/// Shutdown and Dispose racing each other tear the device down exactly once: the data runtime is
/// disposed once (not once per teardown), neither call throws, and the probe loop has exited.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_Shutdown_tears_each_state_down_exactly_once()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var state = drv.GetDeviceState(Host)!;
var probeLoop = state.ProbeTask!;
var data = (SlowTag)factory.Tags["Motor1.Speed"];
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await Task.WhenAll(shutdown, dispose);
data.DisposeCount.ShouldBe(1);
probeLoop.IsCompleted.ShouldBeTrue();
drv.GetDeviceState(Host).ShouldBeNull();
}
/// <summary>
/// An InitializeAsync that a ShutdownAsync overtakes publishes nothing: no device state and no probe
/// loop survive the shutdown. Positive control: an init that nothing overtakes still publishes.
/// </summary>
[Fact]
public async Task An_init_overtaken_by_Shutdown_publishes_no_device_and_starts_no_probe()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var firstProbe = factory.Tags["ProbeTag"];
// The second init's own release waits on the slow first probe loop; the shutdown overtakes it there.
var init = drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(50);
await drv.ShutdownAsync(CancellationToken.None);
var superseded = await Should.ThrowAsync<AbCipInitializationSupersededException>(() => init);
superseded.ShouldNotBeAssignableTo<OperationCanceledException>(); // M4: distinct from a caller cancellation
drv.GetDeviceState(Host).ShouldBeNull();
drv.GetHostStatuses().ShouldBeEmpty();
await Task.Delay(100);
factory.Tags["ProbeTag"].ShouldBeSameAs(firstProbe); // no second probe loop ever created a runtime
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: a later init that nothing overtakes publishes the device again.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Host).ShouldNotBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>
/// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never
/// see a table mid-mutation while the driver re-initialises underneath them, never trip over a per-tag lock a
/// retire disposed, and never leak a runtime they cold-created into a device state a retire already tore down.
/// </summary>
[Fact]
public async Task Readers_survive_repeated_reinitialisation_and_leak_no_runtime()
{
var created = new System.Collections.Concurrent.ConcurrentBag<FakeAbCipTag>();
var factory = new FakeAbCipTagFactory
{
Customise = p =>
{
var t = new FakeAbCipTag(p) { Value = 1 };
created.Add(t);
return t;
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-lifecycle", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var badHealth = 0;
var reader = Task.Run(async () =>
{
var reads = 0;
while (!stop.IsCancellationRequested)
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("Speed");
await drv.ReadAsync(["Speed"], CancellationToken.None);
if (drv.GetHealth().State == DriverState.Degraded) Interlocked.Increment(ref badHealth);
reads++;
started.TrySetResult();
}
return reads;
});
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
for (var i = 0; i < 200; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
stop.Cancel();
(await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran
Volatile.Read(ref badHealth).ShouldBe(0, "a re-init is not a comms fault");
await drv.DisposeAsync();
created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown");
}
/// <summary>F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).</summary>
[Fact]
public async Task DisposeAsync_is_a_complete_idempotent_teardown()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
await drv.DisposeAsync();
await drv.DisposeAsync(); // idempotent
loop.IsCompleted.ShouldBeTrue();
factory.Tags["ProbeTag"].Disposed.ShouldBeTrue();
}
/// <summary>
/// F10: the sync <c>Dispose()</c> no longer blocks the caller's thread on the async teardown (sync-over-async —
/// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still
/// completes.
/// </summary>
[Fact]
public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
await Task.Delay(50); // the probe is inside its slow, cancellation-deaf read
var sw = System.Diagnostics.Stopwatch.StartNew();
((IDisposable)drv).Dispose();
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin)
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15);
drv.GetDeviceState(Host).ShouldBeNull();
}
/// <summary>
/// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over
/// the newer init's Healthy when it finally finishes.
/// </summary>
[Fact]
public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
await Task.Delay(30); // inside the slow probe read
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(30);
await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins
await shutdown;
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetDeviceState(Host).ShouldNotBeNull();
await drv.DisposeAsync();
}
}
@@ -7,7 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
/// transport does this before the wire, e.g. a write it cannot type — an <see cref="AbCipLocalRefusalException"/>
/// since #737 I2) reaches the client as BadNotSupported —
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
/// </summary>
[Trait("Category", "Unit")]
@@ -19,12 +20,12 @@ public sealed class AbCipLocalRefusalTests
{
public override Task ReadAsync(CancellationToken cancellationToken) =>
refuseRead
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
? Task.FromException(new AbCipLocalRefusalException("refused read"))
: base.ReadAsync(cancellationToken);
public override Task WriteAsync(CancellationToken cancellationToken) =>
refuseWrite
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
? Task.FromException(new AbCipLocalRefusalException("refused write"))
: base.WriteAsync(cancellationToken);
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H10: a typo'd (or download-deleted) <c>Probe.ProbeTagPath</c> used to record a Forward-Open
/// FAILURE every probe tick, so the device's backoff window never closed and every cold / evicted data tag read
/// <c>BadCommunicationError</c> forever — a permanent device blackout that a restart did not cure. The controller
/// ANSWERED, so the probe now counts it as reachable: backoff success, host Running, one config Warning.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipProbeRefusalTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver Driver, CapturingLogger Logger) Build(Exception probeFault)
{
var logger = new CapturingLogger();
var factory = new FakeAbCipTagFactory
{
Customise = p => p.TagName == "ProbeTagTypo"
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = probeFault }
: new FakeAbCipTag(p) { Value = 7 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTagTypo",
Interval = TimeSpan.FromMilliseconds(10),
Timeout = TimeSpan.FromMilliseconds(100),
},
}, "abcip-probe-refusal", factory, logger: logger);
return (drv, logger);
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition never met");
await Task.Delay(5);
}
}
/// <summary>A NotFound probe tag leaves the device reachable: data tags still connect, host is Running, warned once.</summary>
[Fact]
public async Task A_missing_probe_tag_does_not_black_out_the_device()
{
var (drv, logger) = Build(new AbTransportException(AbStatus.NotFound, "NotFound"));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running);
await Task.Delay(100); // many probe ticks
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // a cold create, NOT throttled
r[0].Value.ShouldBe(7);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagTypo")).ShouldBe(1);
await drv.DisposeAsync();
}
/// <summary>Positive control: a probe that genuinely cannot connect still stops the host and throttles cold creates.</summary>
[Fact]
public async Task An_unreachable_device_still_stops_the_host_and_throttles()
{
var (drv, _) = Build(new AbTransportException(AbStatus.Timeout, "Timeout"));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Stopped);
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); // throttled by the open window
await drv.DisposeAsync();
}
}
@@ -0,0 +1,96 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 P8 / P7: the bounded-parallel read window. On the native transport every concurrent tag read of a
/// device rides ONE shared request scheduler that packs whatever is queued into a Multiple Service Packet, so a
/// window of 8 capped MSP coalescing at 8 services per round trip; it is widened to at least 64 there (the
/// scheduler's reply budget already prevents over-packing). And a cold create waiting on a device's connect gate
/// must not occupy a window slot, or one dead device's backoff-expiry attempt stalls every other device's reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReadWindowTests
{
private static AbCipDriverOptions Options(int maxConcurrentReads, AbCipTransport? transport = null) => new()
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = maxConcurrentReads,
Transport = transport,
};
/// <summary>The window per transport: native ≥ 64; libplctag and injected factories honour the option exactly.</summary>
[Fact]
public void The_native_window_is_at_least_64_and_other_transports_keep_the_option()
{
new AbCipDriver(Options(8, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
AbCipDriver.NativeMinReadWindow.ShouldBeGreaterThanOrEqualTo(64);
new AbCipDriver(Options(1, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
new AbCipDriver(Options(200, AbCipTransport.Native), "a").ReadWindow.ShouldBe(200);
new AbCipDriver(Options(8), "a", defaultTransport: AbCipTransport.Native).ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow);
// Positive controls: libplctag (per-handle concurrency) and an injected test factory keep the option.
new AbCipDriver(Options(8, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(8);
new AbCipDriver(Options(0, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(1);
new AbCipDriver(Options(3, AbCipTransport.Native), "a", new FakeAbCipTagFactory()).ReadWindow.ShouldBe(3);
}
/// <summary>A tag whose initialisation blocks until released (a cold connect to a slow / dead device).</summary>
private sealed class BlockingInitTag(AbCipTagCreateParams p, Task release) : FakeAbCipTag(p)
{
public override async Task InitializeAsync(CancellationToken cancellationToken)
{
await base.InitializeAsync(cancellationToken);
await release.WaitAsync(cancellationToken);
}
}
/// <summary>
/// With a window of ONE, device B's blocked cold create (and its queued sibling) must not stop device A's tag
/// from being read: cold waiters sit on B's connect gate, not in the window.
/// </summary>
[Fact]
public async Task A_blocked_cold_create_on_one_device_does_not_hold_the_window_against_another()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeAbCipTagFactory
{
Customise = p => p.Gateway == "10.0.0.6"
? new BlockingInitTag(p, release.Task) { Value = 2 }
: new FakeAbCipTag(p) { Value = 1 },
};
const string a = "ab://10.0.0.5/1,0", b = "ab://10.0.0.6/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("B1", b, "B1", AbCipDataType.DInt),
new AbCipTagDefinition("B2", b, "B2", AbCipDataType.DInt),
new AbCipTagDefinition("A1", a, "A1", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-window", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["B1", "B2", "A1"], CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("A1", out var tag) && tag.ReadCount > 0))
{
if (DateTime.UtcNow > deadline)
{
release.TrySetResult();
await read;
throw new ShouldAssertException("device A's read waited behind device B's blocked cold create");
}
await Task.Delay(5);
}
read.IsCompleted.ShouldBeFalse(); // B is still blocked — A was read regardless
release.SetResult();
var r = await read;
r.Select(x => x.Value).ShouldBe([2, 2, 1]); // positive control: B's reads complete once it connects
await drv.DisposeAsync();
}
}
@@ -77,7 +77,7 @@ public sealed class AbCipStatusMapperTests
Status.Ok => AbCipStatusMapper.Good,
Status.Pending => AbCipStatusMapper.GoodMoreData,
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
Status.ErrorNotFound => AbCipStatusMapper.BadNodeIdUnknown, // #737 I2: NoMatch/BadDevice are comms
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
@@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
@@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests
Volatile.Read(ref creates).ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
MismatchWarnings(log).ShouldBe(1);
}
@@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
@@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
MismatchWarnings(log).ShouldBe(1);
}
finally
@@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
}
finally
{
@@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests
Volatile.Read(ref creates).ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
drv.GetHealth().ShouldBe(health);
AssertStateUnchanged(drv.GetHealth(), health);
}
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
}
/// <summary>
/// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may
/// only move forward (review 07 H1 — any data proving the device answered advances it).
/// </summary>
private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before)
{
actual.State.ShouldBe(before.State);
actual.LastError.ShouldBe(before.LastError);
(actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue);
}
#pragma warning restore OTOPCUA0001
}
@@ -99,4 +99,26 @@ public sealed class AbCipUdtMemberLayoutTests
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
}).ShouldBeNull();
}
/// <summary>Review 07 P11: the layout is computed once per member list, not once per member per poll.</summary>
[Fact]
public void GetOrBuild_computes_a_layout_once_per_member_list()
{
IReadOnlyList<AbCipStructureMember> members =
[
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("B", AbCipDataType.Real),
];
var first = AbCipUdtMemberLayout.GetOrBuild(members);
first.ShouldNotBeNull();
first["B"].ShouldBe(4);
AbCipUdtMemberLayout.GetOrBuild(members).ShouldBeSameAs(first);
// Positive control: a different (equal-content) list is its own entry, and a not-groupable verdict caches too.
IReadOnlyList<AbCipStructureMember> copy = [.. members];
AbCipUdtMemberLayout.GetOrBuild(copy).ShouldNotBeSameAs(first);
IReadOnlyList<AbCipStructureMember> withBool = [new AbCipStructureMember("F", AbCipDataType.Bool)];
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
}
}
@@ -0,0 +1,175 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must
/// not latch <c>Degraded</c>; and a good write must not overwrite a <c>Degraded</c> the read path just recorded
/// with <c>Healthy</c> (the batch read path is worst-wins for exactly this reason). A good write proves the device
/// answered, so it advances <c>LastSuccessfulRead</c> without touching the state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipWriteHealthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException("socket reset");
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
Func<AbCipTagCreateParams, FakeAbCipTag>? make = null)
{
var factory = new FakeAbCipTagFactory
{
Customise = make ?? (p => p.TagName == "Broken"
? new FakeAbCipTag(p) { ThrowOnRead = true }
: new RealEncodingTag(p) { Value = 0 }),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt),
new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int),
new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-write-health", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16).</summary>
private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
Value = type switch
{
AbCipDataType.Int => Convert.ToInt16(value),
_ => Convert.ToInt32(value),
};
}
/// <summary>Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent.</summary>
[Fact]
public async Task A_client_conversion_error_writes_no_health()
{
var (drv, factory) = await BuildAsync();
var before = drv.GetHealth();
var r = await drv.WriteAsync(
[
new WriteRequest("D", "not a number"), // FormatException
new WriteRequest("D", new object()), // InvalidCastException
new WriteRequest("I", 1_000_000), // OverflowException
], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
drv.GetHealth().ShouldBe(before);
factory.Tags["D"].WriteCount.ShouldBe(0);
factory.Tags["I"].WriteCount.ShouldBe(0);
}
/// <summary>A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead.</summary>
[Fact]
public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy()
{
var (drv, _) = await BuildAsync();
await drv.ReadAsync(["Broken"], CancellationToken.None);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
await Task.Delay(20);
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastError.ShouldBe(degraded.LastError);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered
}
/// <summary>Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead.</summary>
[Fact]
public async Task A_write_transport_fault_still_degrades()
{
var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p));
var before = drv.GetHealth();
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
factory.Tags["D"].Disposed.ShouldBeTrue();
}
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
[Fact]
public async Task A_good_write_clears_a_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false; // the evicted runtime is re-created healthy
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastError.ShouldBeNull();
}
/// <summary>
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
/// write still clears it.
/// </summary>
[Fact]
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
for (var round = 0; round < 20; round++)
{
fail = true;
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false;
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
}
}
/// <summary>
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
/// good write still clears it.
/// </summary>
[Fact]
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
{
var fail = true;
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
fail = false;
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>The element type of the last <see cref="EncodeArray"/>, if any.</summary>
public AbCipDataType? EncodedArrayType { get; private set; }
/// <summary>The bytes the last <see cref="EncodeArray"/> produced (the shared codec, so conversions throw as in production).</summary>
public byte[]? EncodedArrayBytes { get; private set; }
/// <summary>The element count of the last <see cref="EncodeArray"/>.</summary>
public int EncodedArrayCount { get; private set; }
/// <summary>Encodes an array write through the production codec and records it.</summary>
/// <param name="type">The element type.</param>
/// <param name="values">The element values.</param>
public virtual void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
{
EncodedArrayBytes = AbCipArrayCodec.Encode(type, values);
EncodedArrayType = type;
EncodedArrayCount = values.Count;
}
/// <summary>Marks the tag as disposed.</summary>
public virtual void Dispose() => Disposed = true;
}
@@ -16,8 +16,8 @@ public sealed class LibplctagStatusTranslationTests
[InlineData(Status.Pending, AbStatus.Pending)]
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
[InlineData(Status.ErrorNoMatch, AbStatus.BadConnection)] // #737 I2: not proof the controller answered
[InlineData(Status.ErrorBadDevice, AbStatus.BadConnection)]
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
@@ -148,4 +148,24 @@ public sealed class LibplctagStatusTranslationTests
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam)));
thrown.Status.ShouldBe(AbStatus.BadConnection);
}
/// <summary>
/// #737 I2: libplctag's <c>ErrorNotImplemented</c> is the LIBRARY refusing (no I/O): a local refusal, not a device
/// refusal — it must not count as "the controller answered".
/// </summary>
[Fact]
public async Task NotImplemented_is_a_local_refusal_not_a_device_refusal()
{
var ex = await Should.ThrowAsync<AbTransportException>(() =>
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorNotImplemented")));
ex.ShouldBeOfType<AbCipLocalRefusalException>();
ex.Status.ShouldBe(AbStatus.Unsupported);
AbCipRefusals.IsDeviceRefusal(ex).ShouldBeFalse();
AbCipDriverProbe.IsReachableException(ex).ShouldBeFalse();
// Positive control: the device's own Unsupported refusal still is one.
var device = await Should.ThrowAsync<AbTransportException>(() =>
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorUnsupported")));
AbCipRefusals.IsDeviceRefusal(device).ShouldBeTrue();
}
}
@@ -112,6 +112,163 @@ public sealed class NativeAbCipTagRuntimeTests
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// Review 07 U11: a scalar BOOL authored over a BOOL-array element <c>Bits[n]</c> reads DWORD <c>n / 32</c>
/// (a Logix BOOL array is stored as DWORDs and its CIP element index addresses them) and decodes bit
/// <c>n % 32</c> of it — it used to request element n and report "any of bits 0-7 of the reply set".
/// </summary>
/// <param name="n">The authored BOOL-array element.</param>
/// <param name="dword">The DWORD the request must address.</param>
/// <param name="reply">The DWORD the controller returns (little-endian hex).</param>
/// <param name="expected">The element's value.</param>
[Theory]
[InlineData(37, 1, "20000000", true)] // bit 5 of DWORD 1
[InlineData(36, 1, "20000000", false)] // bit 4 of the same DWORD — the old "low byte != 0" read true
[InlineData(5, 0, "20000000", true)]
[InlineData(1, 0, "01000000", false)] // bit 1, not bit 0
[InlineData(31, 0, "00000080", true)] // top bit of DWORD 0 — outside the low byte entirely
public async Task A_BOOL_array_element_decodes_its_bit_of_its_DWORD(int n, int dword, string reply, bool expected)
{
var ch = Replying("D300", reply);
using var rt = await ReadAsync(ch, Params($"Bits[{n}]", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(expected);
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000"));
}
/// <summary>
/// #737 I1: a family whose BOOL array is element-addressed (the controller answers the DWORD-index request with a
/// plain BOOL, 0xC1) falls back to the ORIGINAL element path <c>Bits[n]</c> — it must neither decode nor write
/// element <c>n / 32</c>. Positive control: the 0xD3 theory above still decodes bit <c>n % 32</c>.
/// </summary>
[Fact]
public async Task A_per_element_BOOL_array_falls_back_to_the_element_path_for_read_and_write()
{
var ch = new FakeCipChannel
{
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : "D2000000C10001")),
};
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None);
await rt.ReadAsync(CancellationToken.None);
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2825" + "0100" + "00000000")); // Bits[37]
rt.EncodeValue(AbCipDataType.Bool, null, false);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37]
}
/// <summary>A channel whose reply can be switched mid-test (read replies; writes always succeed).</summary>
private static FakeCipChannel Switchable(Func<string> readReply) => new()
{
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())),
};
private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32)
private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element)
/// <summary>
/// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it.
/// Tag deployed before the online edit that creates <c>Bits : BOOL[2048]</c>: the init read is refused NotFound;
/// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32
/// (the round-1 code had permanently fallen back to <c>Bits[37]</c> = DWORD 37 and decoded byte 0 as Good).
/// </summary>
/// <param name="refusal">The refusal reply the DWORD request first gets.</param>
[Theory]
[InlineData("D2000400")] // NotFound
[InlineData("D2000F00")] // NotAllowed
[InlineData("D2000A00")] // OutOfBounds
public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal)
{
var reply = refusal;
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status
rt.GetStatus().ShouldNotBe((int)AbStatus.Ok);
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path
reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is
/// discarded and <c>Bits[n]</c> is re-read and decoded.
/// </summary>
[Fact]
public async Task A_refusal_then_a_per_element_reply_settles_the_element_path()
{
var reply = "D2000A00";
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None);
reply = "D2000000C10001";
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL…
ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply
/// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value.
/// </summary>
[Fact]
public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD()
{
var reply = "D2000000C10000";
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool));
await rt.InitializeAsync(CancellationToken.None); // settles per-element
reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear
await rt.ReadAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000"));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0"
}
/// <summary>N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0.</summary>
[Fact]
public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused()
{
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool));
(Should.Throw<AbTransportException>(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch);
}
/// <summary>
/// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE
/// it writes, so the write goes to the right element of a per-element array.
/// </summary>
[Fact]
public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element()
{
var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled
var ch = Switchable(() => reply);
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
reply = "D2000000C10000";
rt.EncodeValue(AbCipDataType.Bool, null, true);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37]
}
/// <summary>Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte.</summary>
[Fact]
public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte()
{
using var rt = await ReadAsync(Replying("C100", "01"), Params("Flag", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{
@@ -129,14 +286,35 @@ public sealed class NativeAbCipTagRuntimeTests
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
}
[Fact]
public async Task Unknown_tag_fails_initialize_with_NotFound()
/// <summary>
/// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works —
/// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the
/// refusal stays readable and every later read reports it again.
/// </summary>
[Theory]
[InlineData("D2000400", AbStatus.NotFound)]
[InlineData("D2000500", AbStatus.NotFound)]
[InlineData("D2000A00", AbStatus.OutOfBounds)]
[InlineData("D2000F00", AbStatus.NotAllowed)]
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
await rt.InitializeAsync(CancellationToken.None);
rt.GetStatus().ShouldBe((int)expected);
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(CancellationToken.None)))
.Status.ShouldBe(expected);
}
/// <summary>Positive control: a reply the transport cannot classify as a refusal still fails initialize.</summary>
[Fact]
public async Task An_unclassified_reply_still_fails_initialize()
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
.Status.ShouldBe(AbStatus.BadReply);
}
[Fact]
@@ -0,0 +1,69 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>
/// The Calculation driver's run-guard options: the node-wide <c>Scripting</c> section is the base, and a key
/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation
/// driver always ran on the compiled defaults whatever the node configured.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CalculationScriptingOptionsResolutionTests
{
private static readonly ScriptingOptions Node = new()
{
MaxAllocBytesPerRun = 8L * 1024 * 1024,
QuarantineAfterConsecutiveTimeouts = 7,
StuckOrphanQuarantineFactor = 4,
};
[Fact]
public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() =>
CalculationDriverFactoryExtensions.ResolveScripting(null, null, null, out _).ShouldBeNull();
[Fact]
public void No_driver_keys_inherits_every_node_knob()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node, out _).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
resolved.StuckOrphanQuarantineFactor.ShouldBe(4);
}
[Fact]
public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node, out var adjustments).ShouldNotBeNull();
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2);
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value");
resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide");
resolved.ShouldNotBeSameAs(Node);
Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
adjustments.ShouldBeEmpty();
}
/// <summary>Review m-2: an out-of-range value authored in the driver's own config is clamped AND reported, so
/// the factory can log it rather than the guard changing it silently.</summary>
[Fact]
public void An_out_of_range_driver_key_is_clamped_and_reported()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(1024L, 0, Node, out var adjustments).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
adjustments.Count.ShouldBe(2);
}
[Fact]
public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults()
{
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null, out _).ShouldNotBeNull();
resolved.MaxAllocBytesPerRun.ShouldBe(0);
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
}
}
@@ -72,6 +72,155 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>
/// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes
/// the request's 32-bit <c>Arg1</c> as the number, so a decoy is seeded at the address the old
/// <c>(short)</c> cast produced (99999 → -31073, 40000 → -25536, as the unsigned key the mock sees):
/// reading the decoy's value means the number was wrapped. #500 / #6711 are the positive control that
/// ordinary numbers still read.
/// </summary>
[Fact]
public async Task Macro_and_parameter_numbers_above_short_range_read_the_authored_variable()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
static string Wrapped(int number) => ((uint)(int)(short)number).ToString(System.Globalization.CultureInfo.InvariantCulture);
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
parameters = new Dictionary<string, object>
{
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
["40000"] = new { type = "long", value = 4242, @decimal = 0 },
[Wrapped(40000)] = new { type = "long", value = -1, @decimal = 0 },
},
macros = new Dictionary<string, object>
{
["500"] = new { value = 42000, @decimal = 3 },
["99999"] = new { value = 7, @decimal = 0 },
[Wrapped(99999)] = new { value = 666, @decimal = 0 },
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
// ThirtyOne_i: the capability matrix admits #99999 (its documented ceiling); the parameter is read on
// an Unknown-series device, which admits any number.
Devices = [new FocasDeviceOptions(DeviceHost, Series: FocasCncSeries.ThirtyOne_i)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("Macro99999", DeviceHost, "MACRO:99999", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-wide-numbers", clientFactory: new WireFocasClientFactory());
var unknownSeries = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Param40000", DeviceHost, "PARAM:40000", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-wide-param", clientFactory: new WireFocasClientFactory());
await using (drv)
await using (unknownSeries)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["Macro500", "Macro99999", "Param6711"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToDouble(snaps[0].Value).ShouldBe(42.0, tolerance: 0.001);
snaps[1].Value.ShouldBe(7, "MACRO:99999 must read #99999, not the wrapped #-31073 decoy (666)");
Convert.ToInt32(snaps[2].Value).ShouldBe(1234);
await unknownSeries.InitializeAsync("{}", ct);
var param = await unknownSeries.ReadAsync(["Param40000"], ct);
param[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToInt32(param[0].Value).ShouldBe(4242, "PARAM:40000 must not read the wrapped decoy (-1)");
}
}
/// <summary>
/// #740 / R07:H13 — re-initialising a live driver (the fixed-tree loop running, reads in flight over the real
/// wire) swaps the device table instead of clearing it under its readers: no reader throws, reads keep
/// returning Good, and shutdown leaves no session behind (a fresh driver can still connect afterwards).
/// </summary>
[Fact]
public async Task Reinitialise_under_live_reads_swaps_the_device_table()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
macros = new Dictionary<string, object> { ["500"] = new { value = 42000, @decimal = 3 } },
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(50),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-reinit-swap", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct);
var errors = new ConcurrentQueue<Exception>();
var good = 0;
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var s = await drv.ReadAsync(["Macro500", $"{DeviceHost}/Identity/SeriesNumber"], CancellationToken.None);
if (s[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { errors.Enqueue(ex); }
}
}, CancellationToken.None);
for (var i = 0; i < 5; i++)
{
await Task.Delay(150, ct);
await drv.InitializeAsync("{}", ct);
}
await Task.Delay(300, ct);
stop.Cancel();
await reader;
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: reads went over the wire between re-initialises");
}
// A fresh driver still connects and reads Good after the re-initialise churn and the shutdown.
var after = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-reinit-after", clientFactory: new WireFocasClientFactory());
await using (after)
{
await after.InitializeAsync("{}", ct);
(await after.ReadAsync(["Macro500"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
}
}
/// <summary>Verifies that discover emits device folder and tag variables.</summary>
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
@@ -0,0 +1,218 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout)
/// against the focas-mock fixture.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendHealthAndLayoutTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of the test class with the FOCAS simulation fixture.</summary>
/// <param name="fx">The FOCAS simulation fixture.</param>
public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx;
/// <summary>
/// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back
/// without stopping a fixture other suites use: <see cref="Down"/> closes every relayed session and stops
/// listening (new connects are refused); <see cref="Up"/> listens again on the same port.
/// </summary>
private sealed class CncRelay : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly List<TcpClient> _live = [];
private TcpListener? _listener;
private CancellationTokenSource? _cts;
public CncRelay(string targetHost, int targetPort)
{
_targetHost = targetHost;
_targetPort = targetPort;
Port = Listen(0);
}
public int Port { get; private set; }
private int Listen(int port)
{
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_listener.Start();
var ct = _cts.Token;
var listener = _listener;
_ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None);
return ((IPEndPoint)_listener.LocalEndpoint).Port;
}
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient inbound;
try { inbound = await listener.AcceptTcpClientAsync(ct); }
catch { return; }
var outbound = new TcpClient();
try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); }
catch { inbound.Dispose(); outbound.Dispose(); continue; }
lock (_live) { _live.Add(inbound); _live.Add(outbound); }
_ = Pump(inbound, outbound, ct);
_ = Pump(outbound, inbound, ct);
}
}
private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct)
{
try { await from.GetStream().CopyToAsync(to.GetStream(), ct); }
catch { /* the relay was taken down, or a side closed */ }
finally { from.Dispose(); to.Dispose(); }
}
public void Down()
{
_cts?.Cancel();
_listener?.Stop();
lock (_live)
{
foreach (var c in _live) c.Dispose();
_live.Clear();
}
}
/// <summary>
/// Listens again on the SAME port (the driver's host string names it). The re-bind is retried briefly: the
/// OS can hold the port for a moment after <see cref="Down"/>, or hand it to another process — the latter
/// surfaces as a clear failure after the retries rather than a random flake.
/// </summary>
public void Up()
{
for (var attempt = 1; ; attempt++)
{
try { Listen(Port); return; }
catch (SocketException) when (attempt < 20) { Thread.Sleep(100); }
}
}
public ValueTask DisposeAsync()
{
Down();
return ValueTask.CompletedTask;
}
}
/// <summary>
/// H15 over the wire: a driver serving only <c>FIXED:</c> tags is Healthy with a moving LastSuccessfulRead
/// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527
/// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control).
/// </summary>
[Fact]
public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await using var relay = new CncRelay(_fx.Host, _fx.Port);
var host = $"focas://127.0.0.1:{relay.Port}";
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(1),
FixedTree = new FocasFixedTreeOptions
{
PollInterval = TimeSpan.FromMilliseconds(50),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory())
{
FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500),
};
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10));
var first = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
relay.Down();
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15));
var frozen = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen");
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError,
"the stale fixed-tree value reads Bad on the same budget that degrades the driver");
// Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff
// (1 s doubling) bounds how soon the loop re-dials.
relay.Up();
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
(await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
}
}
/// <summary>
/// H20 over the wire: the mock answers <c>cnc_rdalmmsg2</c> in the 80-byte LEGACY layout. Nineteen active
/// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty
/// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms.
/// </summary>
[Fact]
public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
var client = new WireFocasClientFactory().Create();
try
{
await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct);
await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct);
var three = await client.ReadAlarmsAsync(ct);
three.Count.ShouldBe(3);
three[2].AlarmNumber.ShouldBe(402);
await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct);
var nineteen = await client.ReadAlarmsAsync(ct);
nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones");
nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19));
nineteen[18].Message.ShouldBe("SERVO ALARM 418");
nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4)));
}
finally
{
client.Dispose();
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
}
static object[] Alarms(int n) => [.. Enumerable.Range(0, n)
.Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })];
}
private static async Task WaitFor(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(25);
}
}
}
@@ -259,7 +259,7 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
lock (Clients) Clients.Add(c);
return c;
}
}
@@ -0,0 +1,114 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H20 — <c>ParseAlarms</c> picked the 76-byte vendor layout whenever <c>Length % 76 == 0</c>, so a
/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The
/// response block carries no record count, so the layout is now chosen from the two things that are known: the
/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmLayoutTests
{
private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records)
{
var buf = new byte[records.Length * 80];
for (var i = 0; i < records.Length; i++)
{
var o = i * 80;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16));
}
return buf;
}
private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records)
{
var buf = new byte[records.Length * 76];
for (var i = 0; i < records.Length; i++)
{
var o = i * 76;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12));
}
return buf;
}
private static (int, int, int, string)[] LegacySet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))];
private static (int, short, short, string)[] VendorSet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))];
/// <summary>The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones.</summary>
[Fact]
public void Nineteen_legacy_records_decode_as_legacy()
{
var payload = Legacy(LegacySet(19));
payload.Length.ShouldBe(1520);
(payload.Length % 76).ShouldBe(0, "the length really is ambiguous");
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(19);
alarms[0].AlarmNumber.ShouldBe(400);
alarms[0].Message.ShouldBe("SERVO ALARM 400");
alarms[18].AlarmNumber.ShouldBe(418);
alarms[18].Axis.ShouldBe((short)(1 + (18 % 4)));
}
/// <summary>Positive control at the same length: 20 vendor records still decode as vendor.</summary>
[Fact]
public void Twenty_vendor_records_decode_as_vendor()
{
var payload = Vendor(VendorSet(20));
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms[0].AlarmNumber.ShouldBe(1000);
alarms[19].Message.ShouldBe("OVERTRAVEL +19");
}
/// <summary>
/// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the
/// int32 read of <c>type&lt;&lt;16 | axis</c> is just the axis, and the "length" int32 is the empty message's
/// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode.
/// </summary>
[Fact]
public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor()
{
var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]);
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20));
alarms.ShouldAllBe(a => a.Type == 0 && a.Message == "");
}
/// <summary>The unambiguous lengths are unchanged.</summary>
[Fact]
public void Unambiguous_lengths_are_unchanged()
{
FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
}
}
@@ -645,7 +645,8 @@ public sealed class FocasAlarmProjectionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
// A COMMS-class status: a CNC refusal (e.g. BadOutOfRange) is not a value-path Degraded since #740.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadCommunicationError;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
@@ -0,0 +1,284 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:P9 — <see cref="FocasDriver.ReadAsync"/> walked the whole batch sequentially, so a SLOW CNC
/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other
/// device was read. Devices are now read concurrently, each still serialised on its own socket.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasConcurrentDeviceReadTests
{
private const string HostA = "focas://10.0.0.5:8193";
private const string HostB = "focas://10.0.0.6:8193";
/// <summary>
/// A client that records which device it dialled. Device A's read only completes once device B's read has
/// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts
/// the peak number of reads in flight on any one client.
/// </summary>
private sealed class OrderingClient(TaskCompletionSource bStarted, int[] global) : FakeFocasClient
{
private string _host = "";
private int _inFlight;
public int PeakInFlight;
public int Reads;
public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
_host = address.ToString();
return base.ConnectAsync(address, timeout, ct);
}
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
var now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref PeakInFlight, now);
Interlocked.Increment(ref Reads);
// global[0] = reads in flight across EVERY client, global[1] = its peak.
InterlockedMax(ref global[1], Interlocked.Increment(ref global[0]));
try
{
if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult();
else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
await Task.Delay(20, ct).ConfigureAwait(false);
return ((sbyte)1, FocasStatusMapper.Good);
}
finally
{
Interlocked.Decrement(ref _inFlight);
Interlocked.Decrement(ref global[0]);
}
}
private static void InterlockedMax(ref int target, int value)
{
int seen;
while ((seen = Volatile.Read(ref target)) < value
&& Interlocked.CompareExchange(ref target, value, seen) != seen) { }
}
}
/// <summary>A stalled device no longer holds every other device's read hostage — and each device stays serialised.</summary>
[Fact]
public async Task Devices_are_read_concurrently_and_each_stays_serialised()
{
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var global = new int[2];
var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted, global) };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
// Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing.
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-p9", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good,
"device A's read can only finish once device B's has started — a sequential walk times A out");
factory.Clients.Count.ShouldBe(2);
var clients = factory.Clients.Cast<OrderingClient>().ToList();
// Every client really served reads (2 on A, 1 on B), so "peak 1" is measured, not vacuous …
clients.Select(c => c.Reads).OrderBy(n => n).ShouldBe([1, 2]);
clients.ShouldAllBe(c => c.PeakInFlight == 1,
"one device's socket is still request→response: never two reads in flight on it");
// … and the two devices overlapped: two reads were in flight at once across the clients.
global[1].ShouldBe(2, "the devices must be read at the same time, not one after the other");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A device that accepted TCP and then stopped answering: every read hangs until the call timeout.</summary>
private sealed class StalledClient : FakeFocasClient
{
public int Reads;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return (null, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 review I1 — <c>Task.WhenAll</c> waits for the slowest device, so concurrency alone still made a
/// stalled device cost (its tag count × the call timeout) per batch, and the healthy device's values waited
/// that long. After the first timeout on a device its remaining tags in the batch fail fast: the batch is
/// bounded by about ONE call timeout and the healthy device's values are Good.
/// </summary>
[Fact]
public async Task A_stalled_device_costs_one_call_timeout_per_batch_not_one_per_tag()
{
var callTimeout = TimeSpan.FromMilliseconds(200);
var factory = new FakeFocasClientFactory();
factory.Customise = () => factory.Clients.Count == 0 ? new StalledClient() : new FakeFocasClient { Values = { ["R100"] = (sbyte)9 } };
var stalledTags = Enumerable.Range(0, 10)
.Select(i => FocasRawTags.Entry(new FocasTagDefinition($"raw/A{i}", "A", $"R{100 + i}", FocasDataType.Byte)));
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags = [.. stalledTags, FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
Timeout = callTimeout,
}, "focas-stall", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Connect device A first so it gets the stalled client (connect order = client order).
await drv.ReadAsync(["raw/A0"], CancellationToken.None);
var refs = Enumerable.Range(0, 10).Select(i => $"raw/A{i}").Append("raw/B1").ToArray();
var sw = System.Diagnostics.Stopwatch.StartNew();
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
sw.Stop();
sw.Elapsed.ShouldBeLessThan(callTimeout * 3, "one stalled device must cost ~one call timeout, not ten");
snaps[10].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[10].Value.ShouldBe((sbyte)9);
snaps.Take(10).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
((StalledClient)factory.Clients[0]).Reads.ShouldBe(2, "one read in the warm-up batch, one in the measured batch");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Counts per-tag reads; one address can be made to time out (throw) or answer a status.</summary>
private sealed class CountingClient : FakeFocasClient
{
public int Reads;
public string? TimeoutAt;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
if (address.Canonical == TimeoutAt)
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
}
}
private static FocasDriver OneDevice(FakeFocasClientFactory factory, bool coalesce, params string[] addresses) => new(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A")],
RawTags = [.. addresses.Select(a => FocasRawTags.Entry(new FocasTagDefinition($"raw/{a}", "A", a, FocasDataType.Byte)))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = coalesce },
Timeout = TimeSpan.FromMilliseconds(200),
}, "focas-linkdown", factory);
/// <summary>
/// #740 review round 2 N1 — a status the CNC RETURNED (EW_REJECT and every unlisted rc map to
/// BadCommunicationError) proves the device answered, so it must not end the device's batch: the tags after it
/// still read. Before the fix, a deterministic rc on the first tag starved every later tag forever.
/// </summary>
[Fact]
public async Task A_returned_comms_status_does_not_fail_the_rest_of_the_batch()
{
var client = new CountingClient { Values = { ["R101"] = (sbyte)1, ["R102"] = (sbyte)2 } };
client.ReadStatuses["R100"] = FocasStatusMapper.MapFocasReturn(13); // EW_REJECT ⇒ BadCommunicationError
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe((sbyte)2);
client.Reads.ShouldBe(3, "every tag was really read");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control for N1: a TIMEOUT (no reply at all) still fails the rest of the batch without reading it.</summary>
[Fact]
public async Task A_timeout_still_fails_the_rest_of_the_batch_fast()
{
var client = new CountingClient { TimeoutAt = "R100", Values = { ["R101"] = (sbyte)1 } };
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.Reads.ShouldBe(1, "only the tag that timed out touched the wire");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review round 2 m5 — the coalescer's link-down paths: a transport fault on the first merged range fails the
/// second range WITHOUT a second range read, and the per-tag tags of the same device without a per-tag read.
/// </summary>
[Fact]
public async Task Coalescer_transport_fault_fails_later_ranges_and_per_tag_tags_without_reading_them()
{
var client = new CountingClient { ThrowOnPmcRange = true };
// Two ranges far apart (gap > MaxGapBytes) + one lone tag that takes the per-tag path.
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: true,
"R100", "R101", "R500", "R501", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R500", "raw/R501", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.PmcRangeReads.Count.ShouldBe(1, "the second range must not pay another transport attempt");
client.Reads.ShouldBe(0, "the per-tag tag must not pay one either");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review round 2 m5 — a coalescer CONNECT failure fails the per-tag tags of the device without a second connect.</summary>
[Fact]
public async Task Coalescer_connect_failure_fails_per_tag_tags_without_reconnecting()
{
var factory = new FakeFocasClientFactory { Customise = () => new CountingClient { ThrowOnConnect = true } };
var drv = OneDevice(factory, coalesce: true, "R100", "R101", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
factory.Clients.Count.ShouldBe(1, "one connect attempt per device per batch");
factory.Clients.Sum(c => c.ConnectCount).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Results land at their own indices whatever order the devices finish in.</summary>
[Fact]
public async Task Results_keep_their_request_order_across_devices()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-p9-order", factory)
;
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } };
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None);
snaps[0].Value.ShouldBe((sbyte)2);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[2].Value.ShouldBe((sbyte)1);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm
/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than
/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A:
/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next
/// attempt's shutdown.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDeviceTableLifecycleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new()
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) },
};
/// <summary>A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied.</summary>
[Fact]
public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds()
{
var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.DeviceTable;
before.Count.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceTable.ShouldNotBeSameAs(before);
drv.DeviceTable.Count.ShouldBe(1);
before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it");
await drv.ShutdownAsync(CancellationToken.None);
before.Count.ShouldBe(1);
drv.DeviceTable.Count.ShouldBe(0);
}
/// <summary>Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed.</summary>
[Fact]
public async Task Readers_survive_repeated_reinitialise()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var good = 0;
var errors = new List<Exception>();
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None);
if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { lock (errors) errors.Add(ex); }
}
});
for (var i = 0; i < 100; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(100);
stop.Cancel();
await reader;
await drv.ShutdownAsync(CancellationToken.None);
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table");
}
/// <summary>A client whose connect parks until released and ignores cancellation.</summary>
private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
}
}
/// <summary>
/// A data-path read parked in connect when shutdown runs must not leave a live client on the released
/// device: the device is retired, so the connect that completes afterwards disposes what it opened.
/// </summary>
[Fact]
public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) };
var drv = new FocasDriver(Options(), "focas-retire", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["raw/R"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(50);
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5));
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client");
factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
/// <summary>
/// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not
/// resurrect the driver when it finally completes: no device table, no loops, no connection.
/// </summary>
[Fact]
public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing()
{
var factory = new FakeFocasClientFactory();
var options = Options(probe: true);
using var gate = new ManualResetEventSlim(false);
var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ =>
{
inRebind.TrySetResult();
gate.Wait(TimeSpan.FromSeconds(10));
return new FocasDriverBinding(options, factory);
});
var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None));
await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.ShutdownAsync(CancellationToken.None);
gate.Set();
await Should.ThrowAsync<OperationCanceledException>(() => init.WaitAsync(TimeSpan.FromSeconds(10)));
await Task.Delay(200); // several probe intervals — a started loop would have connected by now
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: the same driver, initialised without being superseded, does start its probe.
await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>A loop wedged in a read that ignores cancellation for a while.</summary>
private sealed class SlowProbeClient : FakeFocasClient
{
public override async Task<bool> ProbeAsync(CancellationToken ct)
{
await Task.Delay(400, CancellationToken.None).ConfigureAwait(false);
return true;
}
}
/// <summary>
/// Lane A disposes while its own shutdown may still be running. The dispose must not return before the
/// teardown it overlaps has released the client — it waits for it rather than finding an empty table.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() };
var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
factory.Clients.ShouldAllBe(c => !c.IsConnected);
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
drv.DeviceCount.ShouldBe(0);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H15 — the fixed-tree loop swallowed every failure at Debug and never touched driver health, so a
/// driver serving only <c>FIXED:</c> tags (Identity / Axes / Program / Status — the typical FOCAS shape) read
/// Healthy on <c>/hosts</c> with every node Bad, and the #527 comms-loss watchdog could never fire. The loop
/// now credits each answered axis pass (Healthy + LastSuccessfulRead) and, once a device has been silent past
/// the fixed-tree staleness budget, degrades the driver with LastSuccessfulRead frozen.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeHealthTests
{
private const string Host = "focas://10.0.0.5:8193";
private static readonly TimeSpan Stale = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan BlipBudget = TimeSpan.FromMilliseconds(1500);
/// <summary>A CNC that can be switched off (transport failure) or made to refuse the dynamic read.</summary>
private sealed class CncSwitch
{
public volatile bool Down;
public volatile bool Refuse;
/// <summary>Transport failures served so far — what the tests wait on instead of wall-clock delays.</summary>
public int Failures;
/// <summary>Refusals served so far.</summary>
public int Refusals;
}
private sealed class SwitchableCnc(CncSwitch sw) : FakeFocasClient
{
private bool Down => sw.Down;
private bool Refuse => sw.Refuse;
public override Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct)
{
if (!Down) return base.GetSysInfoAsync(ct);
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
public override Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (Down)
{
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
if (Refuse)
{
Interlocked.Increment(ref sw.Refusals);
throw new FocasWireException("cnc_rddynamic2 refused", rc: 6);
}
return base.ReadDynamicAsync(axisIndex, ct);
}
}
private static FocasDriver FixedOnlyDriver(CncSwitch sw, TimeSpan? budget = null) => new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/X", Host, "FIXED:Axes/X/AbsolutePosition", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
PollInterval = TimeSpan.FromMilliseconds(20),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, "focas-fixed-health", new FakeFocasClientFactory { Customise = () => new SwitchableCnc(sw) })
{
FixedTreeStaleBudgetOverride = budget ?? Stale,
};
/// <summary>
/// Positive control first, then the outage: an answering CNC keeps the driver Healthy with a moving
/// LastSuccessfulRead; a CNC that stops answering degrades it once the budget passes, with
/// LastSuccessfulRead frozen (the watchdog's conjunction); and its return restores Healthy.
/// </summary>
[Fact]
public async Task Fixed_only_driver_degrades_when_its_cnc_disappears_and_recovers_when_it_returns()
{
var sw = new CncSwitch();
// A wide budget (review round 2 m4): two 20 ms ticks always land well inside it, so the "blip does not
// degrade" absence check below runs unconditionally instead of being skipped on a slow agent.
await using var drv = FixedOnlyDriver(sw, BlipBudget);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
var first = drv.GetHealth().LastSuccessfulRead!.Value;
// An answered axis pass is a successful read: wait for it to move rather than sleeping.
await WaitUntil(() => drv.GetHealth().LastSuccessfulRead > first);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
sw.Down = true;
var lastAnswer = drv.GetDeviceState(Host)!.LastFixedPollUtc!.Value;
// Two failed ticks have demonstrably happened (positive evidence for the absence check below) …
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= 2);
// … inside the budget (asserted, not assumed), and the driver is still Healthy.
(DateTime.UtcNow - lastAnswer).ShouldBeLessThan(BlipBudget, "the two failed ticks must land inside the budget");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a blip inside the staleness budget does not degrade");
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded);
var frozen = drv.GetHealth().LastSuccessfulRead;
var failuresAtDegrade = Volatile.Read(ref sw.Failures);
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= failuresAtDegrade + 5);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "LastSuccessfulRead must stay frozen while the CNC is silent");
sw.Down = false;
await WaitUntil(() => drv.GetHealth().State == DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
}
/// <summary>A CNC that is down from the start (bootstrap never succeeds) degrades once the budget passes.</summary>
[Fact]
public async Task Bootstrap_that_never_succeeds_degrades_the_driver()
{
var sw = new CncSwitch { Down = true };
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
// The bootstrap retries every 2 s, so the first failure is what has to trip it.
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(6));
sw.Down = false;
}
/// <summary>
/// A CNC that ANSWERS with a refusal is not a comms loss: the transport is fine, so the driver is not
/// degraded (else the watchdog would reconnect-loop a production control).
/// </summary>
[Fact]
public async Task Cnc_refusal_of_the_dynamic_read_does_not_degrade()
{
var sw = new CncSwitch();
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
sw.Refuse = true;
var started = DateTime.UtcNow;
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 1);
var atFirstRefusal = drv.GetHealth().LastSuccessfulRead!.Value;
// Refused ticks really happened, for longer than the staleness budget …
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 5 && DateTime.UtcNow - started > Stale * 2);
// … and the driver stayed Healthy, with each refusal crediting the answer (review minor 2).
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atFirstRefusal,
"a refusal is an answer from the CNC — LastSuccessfulRead advances");
sw.Refuse = false;
}
private static async Task WaitUntil(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -62,6 +62,73 @@ public sealed class FocasHandleRecycleTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A client whose reads take a while — long enough for a recycle tick to land mid-read.</summary>
private sealed class SlowReadClient : FakeFocasClient
{
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
await Task.Delay(200, ct).ConfigureAwait(false);
if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read");
return ((sbyte)5, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing
/// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the
/// device's connect gate and lets the in-flight read finish before disposing the client it detached.
/// </summary>
[Fact]
public async Task Recycle_tick_landing_mid_read_lets_the_read_finish()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Each 200 ms read spans at least three recycle ticks.
for (var i = 0; i < 3; i++)
{
var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick");
snap.Value.ShouldBe((sbyte)5);
}
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
// Positive control: the recycle really ran — the first client was disposed and a fresh one opened.
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
factory.Clients.Count.ShouldBeGreaterThan(1);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned");
}
/// <summary>DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket.</summary>
[Fact]
public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones()
{
var inner = new SlowReadClient();
await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None);
var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
var addr = FocasAddress.TryParse("R100")!;
var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None);
await Task.Delay(20);
sync.PendingOperations.ShouldBe(1);
var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5));
(await read).status.ShouldBe(FocasStatusMapper.Good);
await dispose;
inner.DisposeCount.ShouldBe(1);
await Should.ThrowAsync<ObjectDisposedException>(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None));
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -57,4 +57,32 @@ public sealed class FocasPollErrorHealthTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that
/// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with
/// the live generation (or unstamped) does degrade.
/// </summary>
[Fact]
public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var oldGeneration = drv.LifecycleGeneration;
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = new InvalidOperationException("old batch boom");
stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration;
drv.HandlePollError(stale);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var live = new InvalidOperationException("live batch boom");
live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration;
drv.HandlePollError(live);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control
/// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional
/// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still
/// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver.
/// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC
/// look sick forever.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReadHealthContractTests
{
private const string Host = "focas://10.0.0.5:8193";
private sealed class WarningCounter : ILogger<FocasDriver>
{
private int _warnings;
public int Warnings => Volatile.Read(ref _warnings);
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status)
{
var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } };
client.ReadStatuses["R100"] = r100Status;
var log = new WarningCounter();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log);
return (drv, client, log);
}
/// <summary>A refusal leaves health alone, warns once, and still credits the answer.</summary>
[Theory]
[InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
[InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT
[InlineData(FocasStatusMapper.BadNoData)] // vacant macro
[InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag
[InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal)
{
var (drv, _, log) = NewDriver(refusal);
await drv.InitializeAsync("{}", CancellationToken.None);
var atInit = drv.GetHealth().LastSuccessfulRead;
for (var i = 0; i < 3; i++)
{
await Task.Delay(5);
var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(refusal);
}
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life");
log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A refused tag that recovers and is refused again warns again (the dedup clears on Good).</summary>
[Fact]
public async Task Refusal_warning_rearms_after_the_tag_reads_good()
{
var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
client.ReadStatuses.Remove("R100");
client.Values["R100"] = (sbyte)3;
(await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
log.Warnings.ShouldBe(2);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control: a comms-class status still degrades, preserving LastSuccessfulRead.</summary>
[Fact]
public async Task Comms_status_still_degrades_with_last_successful_read_preserved()
{
var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R101"], CancellationToken.None);
var lastGood = drv.GetHealth().LastSuccessfulRead;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(lastGood);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is
/// refused) is answering, so it is restored to Healthy instead of staying Degraded forever.
/// </summary>
[Fact]
public async Task Refusal_after_a_comms_fault_restores_healthy()
{
var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it");
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A read whose CNC answer is held until released, then fails at the transport.</summary>
private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public volatile bool Hold = true;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
entered.TrySetResult();
await release.ConfigureAwait(false);
throw new IOException("socket reset");
}
}
/// <summary>
/// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not
/// write its Degraded onto the new generation's health. Positive control: the same failure under the live
/// generation does degrade.
/// </summary>
[Fact]
public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var clients = new List<HeldReadClient>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(10),
}, "focas-gen", new FakeFocasClientFactory
{
Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; },
});
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy
release.SetResult();
(await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one");
// Positive control: a failure under the live generation degrades.
var live = drv.ReadAsync(["raw/R100"], CancellationToken.None);
(await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A write the driver refuses (NotSupportedException) is not a comms fault either.</summary>
[Fact]
public async Task Write_refusal_does_not_degrade()
{
var client = new ThrowingWriteClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None);
result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ThrowingWriteClient : FakeFocasClient
{
public override Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
throw new NotSupportedException("bit writes are not supported on this area");
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:U8 — <see cref="FocasDataType.String"/> was authorable on any address, but no area read
/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a
/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the
/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …),
/// whose values really are strings, keeps it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStringDataTypeTests
{
private const string Host = "focas://10.0.0.5:8193";
private static string Config(string address, string dataType) =>
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""";
/// <summary>String on an area read is refused by the mapper.</summary>
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Mapper_refuses_string_on_an_area_read(string address)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse();
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull();
}
/// <summary>Positive control: numeric types on the same addresses, and String on the fixed tree, still map.</summary>
[Theory]
[InlineData("R100", "Byte")]
[InlineData("PARAM:6711", "Int32")]
[InlineData("MACRO:500", "Float64")]
[InlineData("FIXED:Identity/SeriesNumber", "String")]
[InlineData("FIXED:Program/Name", "String")]
public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue();
def.Address.ShouldBe(address);
}
/// <summary>An ALARM: tag has no value, so its data type is irrelevant and String is not refused.</summary>
[Fact]
public void Mapper_accepts_string_on_an_alarm_tag()
{
FocasTagDefinitionFactory.FromTagConfig(
"""{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue();
}
/// <summary>An unparseable address is left to the address check — it still draws THAT diagnosis.</summary>
[Fact]
public void Unparseable_address_keeps_its_own_diagnosis()
{
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull();
FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue();
}
/// <summary>The deploy-time inspector names the reason rather than falling to the generic backstop.</summary>
[Fact]
public void Inspect_names_the_string_refusal()
{
var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String"));
warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal));
FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty();
}
/// <summary>
/// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag
/// beside it reads normally (positive control).
/// </summary>
[Fact]
public async Task Driver_skips_and_reports_a_string_area_tag()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
new RawTagEntry("raw/Str", Config("R100", "String"), false, Host),
new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-u8", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[0].Value.ShouldBeNull();
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe((sbyte)7);
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up
/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter
/// cast every number to <c>short</c>: <c>MACRO:99999</c> asked the CNC for <c>#-31073</c> and published
/// that variable Good under the authored tag. Numbers now travel as <c>int</c> end to end.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWideNumberTests
{
/// <summary>The macro request carries the full number in both 32-bit argument slots.</summary>
[Theory]
[InlineData(500)]
[InlineData(32767)]
[InlineData(32768)]
[InlineData(99999)]
public void Macro_request_carries_the_unnarrowed_number(int number)
{
var block = FocasWireClient.MacroRequest(number, pathId: 1);
block.Command.ShouldBe((ushort)0x0015);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(number);
}
/// <summary>
/// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real
/// 31i-B answered in the <c>ref-macro-500-0x0015</c> capture, so widening changed nothing below 32768.
/// </summary>
[Fact]
public void Macro_request_for_500_matches_the_live_capture_bytes()
{
var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]);
Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", ""));
}
/// <summary>A global parameter puts its number in both arguments; an axis parameter puts the axis second.</summary>
[Theory]
[InlineData(6711, (short)0, 6711)]
[InlineData(40000, (short)0, 40000)]
[InlineData(40000, (short)2, 2)]
public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2)
{
var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1);
block.Command.ShouldBe((ushort)0x000e);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(expectedArg2);
}
/// <summary>The decoded macro reports the number that was asked for, not a wrapped one.</summary>
[Fact]
public void Parsed_macro_keeps_a_number_above_short_range()
{
var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 };
var m = FocasWireClient.ParseMacro(99999, payload);
m.Number.ShouldBe(99999);
m.Scaled.ShouldBe(1.0);
}
/// <summary>
/// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused
/// rather than wrapped onto a different address (Unknown series admits any number).
/// </summary>
[Theory]
[InlineData(100, 1, true)]
[InlineData(65535, 1, true)]
[InlineData(65534, 2, true)]
[InlineData(65535, 2, false)]
[InlineData(70000, 1, false)]
public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok)
{
WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok);
if (ok)
{
((int)start).ShouldBe(number);
((int)end).ShouldBe(number + width - 1);
}
}
}
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
/// <summary>
/// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture
/// (skipped without it, like <see cref="TwinCAT3SmokeTests"/>). Every case that writes restores what it
/// changed: <c>GVL_Primitives.vWord</c> and <c>vString</c> are seeds the primitives theory reads.
/// </summary>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCATRemediationGateTests(TwinCATXarFixture sim)
{
private string Host => $"ads://{sim.TargetNetId}:{sim.AmsPort}";
private TwinCATDriver NewDriver(string id, bool native, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host, "XAR-VM")],
RawTags = TwinCATRawTags.Entries(tags),
UseNativeNotifications = native,
Timeout = TimeSpan.FromSeconds(5),
Probe = new TwinCATProbeOptions { Enabled = false },
}, id);
/// <summary>H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering.</summary>
[TwinCATFact]
public async Task Native_subscribe_isolates_an_unknown_symbol()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h11", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt),
new TwinCATTagDefinition("Typo", Host, "GVL_Fixture.nCounterTypo", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Counter", "Typo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (events.Count(e => e.FullReference == "Counter") < 2 && DateTime.UtcNow < deadline)
await Task.Delay(50, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
events.Count(e => e.FullReference == "Counter" && e.Snapshot.StatusCode == 0u).ShouldBeGreaterThanOrEqualTo(2);
events.First(e => e.FullReference == "Typo").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refused symbol is not a comms fault");
}
/// <summary>H12: native pushes alone advance LastSuccessfulRead.</summary>
[TwinCATFact]
public async Task Native_pushes_advance_LastSuccessfulRead()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h12", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
var handle = await drv.SubscribeAsync(["Counter"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await Task.Delay(1500, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(500));
}
/// <summary>
/// H18: a bit write on the 16-bit <c>vWord</c> (0xBEEF) flips exactly that bit at the WORD's own width
/// (the old 32-bit UDInt RMW could not even address a WORD), and a bit beyond it is refused.
/// </summary>
[TwinCATFact]
public async Task Bit_write_on_a_WORD_flips_one_bit_and_refuses_bit_16()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h18", native: false,
new TwinCATTagDefinition("Word", Host, "GVL_Primitives.vWord", TwinCATDataType.UInt, Writable: false),
new TwinCATTagDefinition("Bit4", Host, "GVL_Primitives.vWord.4", TwinCATDataType.Bool),
new TwinCATTagDefinition("Bit16", Host, "GVL_Primitives.vWord.16", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var before = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
var bit4WasSet = (before & 0x10) != 0;
try
{
(await drv.WriteAsync([new WriteRequest("Bit4", !bit4WasSet)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var after = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
after.ShouldBe((ushort)(before ^ 0x10), "only bit 4 may change");
(await drv.WriteAsync([new WriteRequest("Bit16", true)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
finally
{
await drv.WriteAsync([new WriteRequest("Bit4", bit4WasSet)], CancellationToken.None);
}
Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value).ShouldBe(before);
}
/// <summary>U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements).</summary>
[TwinCATFact]
public async Task Whole_array_write_round_trips()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-array", native: false,
new TwinCATTagDefinition("Arr", Host, "GVL_Arrays.aReal1D", TwinCATDataType.Real, ArrayLength: 32));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var values = Enumerable.Range(0, 32).Select(i => i * 1.5 + 0.25).ToArray();
(await drv.WriteAsync([new WriteRequest("Arr", values)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var read = (await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken))[0];
read.StatusCode.ShouldBe(0u);
((float[])read.Value!).ShouldBe(values.Select(v => (float)v).ToArray());
(await drv.WriteAsync([new WriteRequest("Arr", values[..31])], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
/// <summary>U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused.</summary>
[TwinCATFact]
public async Task String_write_round_trips_and_refuses_overflow()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-string", native: false,
new TwinCATTagDefinition("S", Host, "GVL_Primitives.vString", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var original = (string)(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value!;
try
{
(await drv.WriteAsync([new WriteRequest("S", "Grüße 40°")], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe("Grüße 40°");
(await drv.WriteAsync([new WriteRequest("S", new string('x', 81))], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange, "vString is STRING(80)");
}
finally
{
await drv.WriteAsync([new WriteRequest("S", original)], CancellationToken.None);
}
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(original);
}
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -56,10 +57,22 @@ internal class FakeTwinCATClient : ITwinCATClient
{
ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
if (BlockConnect is { } block)
{
// Models the real client's blocking SDK Connect: ignores the token until released.
return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default);
}
IsConnected = true;
return Task.CompletedTask;
}
/// <summary>When set, <see cref="ConnectAsync"/> does not complete (and ignores cancellation) until this
/// is completed — a probe wedged in the synchronous ADS <c>Connect</c>.</summary>
public TaskCompletionSource? BlockConnect { get; set; }
/// <summary>Gets or sets the simulated notification-queue drop count.</summary>
public long DroppedNotificationCount { get; set; }
/// <summary>Simulates reading a value from a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to read.</param>
/// <param name="type">The data type of the symbol.</param>
@@ -70,16 +83,31 @@ internal class FakeTwinCATClient : ITwinCATClient
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (BlockRead is { } block)
{
// A read wedged in the (uncancellable) wire call; completes with the normal answer once released.
return block.Task.ContinueWith(_ =>
{
BlockRead = null;
return ReadValueAsync(symbolPath, type, bitIndex, arrayCount, CancellationToken.None).Result;
}, TaskScheduler.Default);
}
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SingleReadCount++;
Interlocked.Increment(ref _singleReadCount);
LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
}
/// <summary>When set, <see cref="ReadValueAsync"/> does not complete (and ignores cancellation) until this is
/// completed — a poll read wedged in the wire call.</summary>
public TaskCompletionSource? BlockRead { get; set; }
private int _singleReadCount;
/// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary>
public int SingleReadCount { get; private set; }
public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); }
/// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary>
public List<int> SumReadBatches { get; } = new();
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
@@ -130,21 +158,102 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (type is TwinCATDataType.String or TwinCATDataType.WString
&& SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (resolve → CheckRead → codec at the PLC width).
var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value;
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
return Task.FromResult(status);
}
/// <summary>Gets the log of every <see cref="WriteArrayAsync"/> call.</summary>
public List<(string symbol, TwinCATDataType type, IReadOnlyList<object> elements)> ArrayWriteLog { get; } = new();
/// <summary>Simulates an array write: records it, stores the elements and answers from <see cref="WriteStatuses"/>.</summary>
/// <param name="symbolPath">The array symbol path.</param>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode).
var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
ArrayWriteLog.Add((symbolPath, type, elements));
Values[symbolPath] = elements;
return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
}
/// <summary>Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's
/// encode plan and lands its bytes in <see cref="RawWrites"/> (#739 fix round 1).</summary>
public Dictionary<string, TwinCATPlcSymbolShape> SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Resolved PLC array shapes for <see cref="SymbolShapes"/> entries that are arrays.</summary>
public Dictionary<string, TwinCATPlcArrayShape> ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The raw bytes the encode plan produced, by symbol.</summary>
public ConcurrentDictionary<string, byte[]> RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.</summary>
public Dictionary<string, byte[]> ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Optional parent shapes; absent = an unsigned integer word as wide as its <see cref="ParentWords"/> entry.</summary>
public Dictionary<string, TwinCATPlcSymbolShape> ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of every <see cref="WriteBitAsync"/> call.</summary>
public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
/// <summary>When set, <see cref="WriteBitAsync"/> yields between its read and its write so a missing
/// per-parent lock in the driver loses updates.</summary>
public bool YieldInBitWrite { get; set; }
/// <summary>Simulates the parent-width bit RMW with the driver's own <see cref="TwinCATBitAccess.ApplyBitWrite"/>.</summary>
/// <param name="parentPath">The parent word's symbol path.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value));
if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good)
return readStatus;
byte[] current;
lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : [];
var shape = ParentShapes.TryGetValue(parentPath, out var sh)
? sh
: new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT");
if (YieldInBitWrite) await Task.Yield();
var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated);
if (status != TwinCATStatusMapper.Good) return status;
lock (ParentWords) ParentWords[parentPath] = updated;
return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good;
}
/// <summary>Simulates probing the connection status.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the probe result.</returns>
public virtual Task<bool> ProbeAsync(CancellationToken ct)
{
Interlocked.Increment(ref _probeCount);
if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult);
}
private int _probeCount;
/// <summary>Gets how many times <see cref="ProbeAsync"/> ran — a measured window for absence assertions.</summary>
public int ProbeCount => Volatile.Read(ref _probeCount);
/// <summary>Releases unmanaged resources.</summary>
public virtual void Dispose()
{
@@ -160,9 +269,19 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, int> _addAttempts = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many times <see cref="AddNotificationAsync"/> was called for <paramref name="symbolPath"/>.</summary>
/// <param name="symbolPath">The symbol path.</param>
/// <returns>The attempt count.</returns>
public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate BEFORE applying
/// <see cref="AddNotificationErrors"/> — wedges an attempt in flight whose outcome is a failure.</summary>
public TaskCompletionSource? PreAttemptGate { get; set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary>
public TaskCompletionSource? AddNotificationGate { get; set; }
@@ -181,6 +300,8 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
_addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1);
if (PreAttemptGate is { } pre) await pre.Task.ConfigureAwait(false);
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
@@ -0,0 +1,199 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 U9: array writes and STRING writes go through the same codecs the read side decodes with.
/// Arrays were advertised writable but every write threw InvalidCastException (reported BadTypeMismatch);
/// strings bypassed the codec (host-ANSI encoding, no length check against STRING(n)).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATArrayStringWriteTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
// ---- string codec: encode ----
/// <summary>A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252.</summary>
[Fact]
public void EncodeString_is_NUL_padded_Windows1252_at_the_PLC_width()
{
TwinCATStringCodec.EncodeString("AB", 4, utf8: false).ShouldBe(new byte[] { 0x41, 0x42, 0, 0 });
TwinCATStringCodec.EncodeString("ä€", 3, utf8: false).ShouldBe(new byte[] { 0xE4, 0x80, 0 });
TwinCATStringCodec.EncodeString("ä", 3, utf8: true).ShouldBe(new byte[] { 0xC3, 0xA4, 0 });
TwinCATStringCodec.EncodeString("", 2, utf8: false).ShouldBe(new byte[] { 0, 0 });
}
/// <summary>The encode is the decode's inverse (a STRING(n) of exactly n characters fits).</summary>
[Fact]
public void EncodeString_round_trips_through_DecodeString()
{
foreach (var utf8 in new[] { false, true })
{
var bytes = TwinCATStringCodec.EncodeString("Grüße 40°", 81, utf8);
TwinCATStringCodec.DecodeString(bytes, utf8).ShouldBe("Grüße 40°");
}
TwinCATStringCodec.EncodeString("ABC", 4, utf8: false).Length.ShouldBe(4);
}
/// <summary>A value longer than STRING(n), or a character the target encoding cannot hold, is out of range.</summary>
[Fact]
public void EncodeString_refuses_a_value_that_does_not_fit()
{
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL
}
/// <summary>A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips.</summary>
[Fact]
public void EncodeWString_is_UTF16LE_with_a_terminator()
{
TwinCATStringCodec.EncodeWString("Hi", 6).ShouldBe(new byte[] { 0x48, 0, 0x69, 0, 0, 0 });
TwinCATStringCodec.DecodeWString(TwinCATStringCodec.EncodeWString("中文", 22)).ShouldBe("中文");
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeWString("Hi", 4));
}
// ---- array codec: elements + encode ----
/// <summary>
/// A CLR list converts element-wise to the declared type (ScadaBridge writes an <c>int[]</c> to an
/// Int16 node); the element type is converted, not exact-matched.
/// </summary>
[Fact]
public void ToWriteElements_converts_each_element_to_the_declared_type()
{
var elements = TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, -2, 300 }, 3);
elements.ShouldBe(new object[] { (short)1, (short)-2, (short)300 });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Real, new List<object> { 1, 2.5 }, 2)
.ShouldBe(new object[] { 1f, 2.5f });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2)
.ShouldBe(new object[] { "a", "b" });
}
/// <summary>A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch.</summary>
[Fact]
public void ToWriteElements_refuses_a_wrong_shape()
{
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3));
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1));
}
/// <summary>Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode.</summary>
[Fact]
public void Encode_is_the_inverse_of_Decode()
{
var intLayout = new TwinCATArrayLayout(2, 2);
var intBytes = TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1, (short)-2], intLayout, utf8: false);
intBytes.ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF });
RoundTrip(TwinCATDataType.Bool, [true, false, true], new TwinCATArrayLayout(3, 1));
RoundTrip(TwinCATDataType.DInt, [int.MinValue, 7], new TwinCATArrayLayout(2, 4));
RoundTrip(TwinCATDataType.ULInt, [ulong.MaxValue], new TwinCATArrayLayout(1, 8));
RoundTrip(TwinCATDataType.LReal, [1.5, -2.25], new TwinCATArrayLayout(2, 8));
RoundTrip(TwinCATDataType.String, ["ab", "Grüße"], new TwinCATArrayLayout(2, 11));
RoundTrip(TwinCATDataType.WString, ["中", "x"], new TwinCATArrayLayout(2, 8));
// TIME elements arrive as the encoded uint ms (ConvertForWrite) and decode back to double ms.
var time = TwinCATArrayCodec.Encode(TwinCATDataType.Time,
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Time, new[] { 1500.0, 20.0 }, 2), new TwinCATArrayLayout(2, 4), false);
TwinCATArrayCodec.Decode(TwinCATDataType.Time, time, new TwinCATArrayLayout(2, 4), false)
.ShouldBe(new[] { 1500.0, 20.0 });
}
/// <summary>A string element longer than the element's STRING(n) is out of range.</summary>
[Fact]
public void Encode_refuses_a_string_element_wider_than_its_stride()
{
Should.Throw<OverflowException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false));
Should.Throw<ArgumentException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1], new TwinCATArrayLayout(2, 2), utf8: false));
}
private static void RoundTrip(TwinCATDataType type, object[] elements, TwinCATArrayLayout layout)
{
var bytes = TwinCATArrayCodec.Encode(type, elements, layout, utf8: false);
bytes.Length.ShouldBe(layout.ByteLength);
var decoded = TwinCATArrayCodec.Decode(type, bytes, layout, utf8: false);
decoded.Cast<object>().ShouldBe(elements);
}
// ---- driver ----
private static (TwinCATDriver drv, FakeTwinCATClient fake) NewDriver(params TwinCATTagDefinition[] tags)
{
var fake = new FakeTwinCATClient();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake });
return (drv, fake);
}
/// <summary>An array tag's write sends ArrayLength converted elements through WriteArrayAsync.</summary>
[Fact]
public async Task An_array_write_sends_converted_elements()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Buf", new[] { 1, 2, 3 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
var write = fake.ArrayWriteLog.ShouldHaveSingleItem();
write.symbol.ShouldBe("MAIN.Buf");
write.elements.ShouldBe(new object[] { (short)1, (short)2, (short)3 });
fake.WriteLog.ShouldBeEmpty(); // never the scalar path (that threw InvalidCastException)
}
/// <summary>
/// A length mismatch is BadOutOfRange and a scalar value BadTypeMismatch — nothing reaches the wire
/// and health is untouched (a refusal is not a comms fault). Positive control: the good write above.
/// </summary>
[Fact]
public async Task A_wrong_shape_array_write_is_refused_before_the_wire()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, 2 }),
new WriteRequest("Buf", 5),
new WriteRequest("Buf", new[] { 1, 2, 70_000 }),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[
TwinCATStatusMapper.BadOutOfRange,
TwinCATStatusMapper.BadTypeMismatch,
TwinCATStatusMapper.BadOutOfRange,
]);
fake.ArrayWriteLog.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault.</summary>
[Fact]
public async Task An_over_long_string_write_is_BadOutOfRange()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ThrowOnWrite = true;
fake.Exception = new OverflowException("6 bytes do not fit STRING(4)");
var result = await drv.WriteAsync([new WriteRequest("Name", "toolong")], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 / R02:P1 — TwinCAT is an <see cref="IBatchSubscribable"/>. DriverInstanceActor subscribes ONLY the
/// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the
/// Bad snapshots the native path publishes itself — must reach <c>OnDataChangeBatch</c>; the per-tag event
/// keeps firing for legacy consumers (the CLI).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATBatchSubscribeTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory);
/// <summary>The driver advertises the batch capability.</summary>
[Fact]
public void Driver_is_batch_subscribable() =>
NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo<IBatchSubscribable>();
/// <summary>A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.</summary>
[Fact]
public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } },
};
var drv = NewDriver(factory, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
var single = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
drv.OnDataChange += (_, e) => single.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3));
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var first = batches.First();
first.SubscriptionHandle.ShouldBe(handle);
first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true);
single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>A native push and the native path's own Bad snapshot both reach the batch event.</summary>
[Fact]
public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event()
{
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
_ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.FireNotification("MAIN.A", 42);
var items = batches.SelectMany(b => b.Items).ToList();
items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
var a = items.Single(i => i.FullReference == "A");
a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
a.Snapshot.Value.ShouldBe(42);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -55,4 +55,50 @@ public sealed class TwinCATBitAccessTests
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
}
// ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ----
/// <summary>A bit write flips exactly one bit of the parent's raw bytes at the parent's width.</summary>
[Fact]
public void ApplyBitWrite_flips_one_bit_at_the_parent_width()
{
var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good);
set.ShouldBe(new byte[] { 0x01, 0x02 });
TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good);
cleared.ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// <summary>
/// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as
/// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange.
/// </summary>
[Fact]
public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit()
{
var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good);
updated.ShouldBe(new byte[] { 0x01, 0x80 });
}
/// <summary>A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced.</summary>
[Fact]
public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have()
{
var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange);
none.ShouldBeEmpty();
var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
/// <summary>A parent read shorter than the parent's width is never written back (it would zero bytes).</summary>
[Fact]
public void ApplyBitWrite_refuses_a_short_parent_buffer()
{
var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none)
.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
none.ShouldBeEmpty();
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>Collects the driver's Warning-level messages (thread-safe) for once-per-X assertions.</summary>
internal sealed class TwinCATCapturingLogger : ILogger<TwinCATDriver>
{
private readonly List<string> _warnings = [];
/// <summary>Gets a snapshot of the formatted Warning messages logged so far.</summary>
public IReadOnlyList<string> Warnings { get { lock (_warnings) return [.. _warnings]; } }
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning) lock (_warnings) _warnings.Add(formatter(state, exception));
}
}
@@ -0,0 +1,163 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// The driver health contract (#739 H12): data proving the device answered — a native notification or
/// a successful probe — advances <c>LastSuccessfulRead</c>; sustained probe/connect failure degrades with
/// it preserved; a refusal (a write the client cannot express, a replay the PLC refuses) is no comms fault.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATHealthContractTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native = true) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
/// <summary>A native push advances <c>LastSuccessfulRead</c> and restores Healthy after a comms-fault write.</summary>
[Fact]
public async Task A_native_notification_advances_LastSuccessfulRead_and_restores_Healthy()
{
var fake = new FakeTwinCATClient();
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
initStamp.ShouldNotBeNull();
// A write that fails on the wire degrades (comms fault) with the stamp preserved.
fake.ThrowOnWrite = true;
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(initStamp);
// Condition, not a fixed delay: the clock only has to move past the stamp (the Degraded state means
// the notification is not inside the Healthy throttle window).
await WaitForAsync(() => DateTime.UtcNow > initStamp!.Value, TimeSpan.FromSeconds(2));
fake.FireNotification("MAIN.Speed", 7);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp!.Value);
}
/// <summary>A successful probe advances <c>LastSuccessfulRead</c> in a driver that serves only notifications.</summary>
[Fact]
public async Task A_successful_probe_advances_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp.AddMilliseconds(250), TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(250));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// An unreachable AMS router (every probe connect throws) degrades the driver after
/// <see cref="TwinCATDriver.ProbeFailuresBeforeDegraded"/> consecutive failures, preserving the stamp —
/// before, the probe caught it and health stayed Healthy forever.
/// </summary>
[Fact]
public async Task Consecutive_probe_failures_degrade_preserving_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = true } };
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(initStamp);
factory.Clients.Count.ShouldBeGreaterThanOrEqualTo(TwinCATDriver.ProbeFailuresBeforeDegraded);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// A write the client refuses with <see cref="NotSupportedException"/> is <c>BadNotSupported</c> with no
/// health write (#611: a refusal is not a comms fault). Positive control: a wire failure does degrade.
/// </summary>
[Fact]
public async Task A_NotSupported_write_leaves_health_alone()
{
var fake = new FakeTwinCATClient { ThrowOnWrite = true, Exception = new NotSupportedException("no") };
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.Exception = new InvalidOperationException("wire");
result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// A reconnect replay the PLC REFUSES (an isolated type-mismatch tag re-attempted on every reconnect)
/// republishes the refusal's status and does not degrade. Positive control: a replay that fails on the
/// wire degrades (covered for the same flow in <c>TwinCATReconnectReplayTests</c>).
/// </summary>
[Fact]
public async Task A_refused_replay_republishes_the_refusal_status_without_degrading()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
if (++build >= 2)
c.AddNotificationErrors["MAIN.Speed"] = new TwinCATTypeMismatchException("MAIN.Speed", "now a STRING");
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].SimulateWireDrop();
// A write reconnects (and replays) without itself touching health on success.
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 L8 + the Lane A runtime contract (final stop = <c>ShutdownAsync</c> then <c>DisposeAsync</c>,
/// bounded 5 s + 250 ms): a probe wedged in the blocking ADS connect cannot hang teardown; dispose may run
/// while a shutdown is still running; an abandoned initialise overlapping the next shutdown never installs
/// its generation; a value queued before unsubscribe is not delivered after it; re-init swaps its tables
/// and retracts the previous generation's notifications; queue drops are reported.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATLifecycleTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe = true, bool native = true,
TwinCATCapturingLogger? log = null, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags.Length > 0
? tags
: [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe
? new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30), Timeout = TimeSpan.FromMilliseconds(100) }
: new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory, log);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
/// <summary>
/// A probe wedged in the (uncancellable) connect no longer hangs ShutdownAsync — the join is bounded —
/// and the client the wedged connect eventually produces is disposed, not installed on a dead device.
/// </summary>
[Fact]
public async Task Shutdown_is_bounded_when_a_probe_is_wedged_in_connect()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
factory.Clients.Count.ShouldBe(1); // the probe is inside the wedged connect
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
await WaitForAsync(() => factory.Clients[0].DisposeCount == 1, TimeSpan.FromSeconds(2));
factory.Clients[0].DisposeCount.ShouldBe(1);
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// DisposeAsync while a ShutdownAsync is still running (joining a wedged probe) returns promptly and
/// both complete without throwing; the device is released exactly once.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_shutdown_completes_promptly()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var sw = Stopwatch.StartNew();
await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// Ordering is the real assertion: dispose finished while the first shutdown was still joining the
// wedged probe. The elapsed bound is generous (the runtime allows 250 ms; a loaded CI box gets 1 s).
shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
wedge.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
drv.Dispose(); // and a late sync Dispose is a no-op
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// An InitializeAsync overtaken by a ShutdownAsync before it commits must not install its devices or
/// start probe loops when it resumes. Positive control: a fresh initialise afterwards installs normally.
/// </summary>
[Fact]
public async Task An_initialise_overtaken_by_shutdown_before_commit_installs_nothing()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory);
var paused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.BeforeCommitForTest = async () => { paused.TrySetResult(); await resume.Task; };
var abandoned = drv.InitializeAsync("{}", CancellationToken.None);
await paused.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
resume.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// Positive control + measured window: a fresh generation's probe creates exactly one client and
// ticks several times; an overtaken-generation probe loop (same interval, started earlier) would have
// created a second client within that window.
drv.BeforeCommitForTest = null;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.DeviceCount.ShouldBe(1);
await WaitForAsync(() => factory.Clients.Count >= 1 && factory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
factory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
factory.Clients.Count.ShouldBe(1, "no probe loop of the overtaken generation may be running");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// The Lane A shape: an InitializeAsync the runtime abandoned while it is still releasing the previous
/// generation (joining a probe wedged in connect) overlaps the next ShutdownAsync. Shutdown stays
/// bounded and, once everything settles, no device, probe loop or live client survives.
/// </summary>
[Fact]
public async Task An_abandoned_initialise_overlapping_shutdown_leaves_nothing_running()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient { BlockConnect = first ? wedge : null };
first = false;
return c;
},
};
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
await WaitForAsync(() => drv.DeviceCount == 1 && factory.Clients.Count >= 2, TimeSpan.FromSeconds(3)); // gen 2 committed + probing
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
try { await abandoned.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); }
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested) { }
drv.DeviceCount.ShouldBe(0);
await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
var settled = factory.Clients.Count;
// Measured window: a sibling driver on the same probe interval ticks 4 times; any surviving probe
// loop of the torn-down driver would have built a new client in that time.
var witnessFactory = new FakeTwinCATClientFactory();
var witness = NewDriver(witnessFactory);
await witness.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => witnessFactory.Clients.Count >= 1 && witnessFactory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
witnessFactory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
await witness.ShutdownAsync(TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
}
/// <summary>
/// A value the client queued before the unsubscribe is dropped if dispatched after it.
/// Positive control: the same callback delivers while subscribed.
/// </summary>
[Fact]
public async Task A_notification_dispatched_after_unsubscribe_is_dropped()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var callback = factory.Clients[0].Notifications.Single().OnChange; // what the client's queue holds
callback("MAIN.Speed", 1);
events.Count.ShouldBe(1);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
callback("MAIN.Speed", 2);
events.Count.ShouldBe(1);
}
/// <summary>
/// A direct re-initialise with a changed config swaps the tag table (the removed tag is unknown, the
/// added one resolves) and retracts the previous generation's live notifications.
/// </summary>
[Fact]
public async Task Reinitialise_swaps_tables_and_retracts_the_previous_generations_notifications()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.Count.ShouldBe(1);
var next = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
rawTags = new[]
{
new
{
rawPath = "Torque",
tagConfig = TwinCATTagDefinitionFactory.ToTagConfig(
new TwinCATTagDefinition("Torque", Host, "MAIN.Torque", TwinCATDataType.DInt)),
writeIdempotent = false,
deviceName = Host,
},
},
probe = new { enabled = false },
});
await drv.InitializeAsync(next, TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.ShouldBeEmpty();
factory.Clients[0].DisposeCount.ShouldBe(1);
(await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
(await drv.ReadAsync(["Torque"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>Reads racing a stream of re-initialises never throw (no table is ever mid-Clear).</summary>
[Fact]
public async Task Reads_racing_reinitialise_never_throw()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Run for at least 300 ms of overlap AND until the reader has seen a Good read mid-churn (positive
// control), bounded at 10 s — a condition, not a fixed window, so a loaded machine cannot starve it.
var good = 0;
var reads = 0;
var inits = 0;
var minUntil = DateTime.UtcNow.AddMilliseconds(300);
var deadline = DateTime.UtcNow.AddSeconds(10);
bool Done() => (DateTime.UtcNow > minUntil && Volatile.Read(ref good) > 0 && Volatile.Read(ref inits) > 10)
|| DateTime.UtcNow > deadline;
var reader = Task.Run(async () =>
{
while (!Done())
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
Interlocked.Increment(ref reads);
if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
}
}, TestContext.Current.CancellationToken);
while (!Done())
{
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
Interlocked.Increment(ref inits);
await Task.Yield(); // let the reader in between generations
}
await reader; // throws if any read threw
good.ShouldBeGreaterThan(0);
inits.ShouldBeGreaterThan(10);
}
/// <summary>Notification-queue drops are reported once per probe tick that sees new drops.</summary>
[Fact]
public async Task Notification_queue_drops_are_reported()
{
var fake = new FakeTwinCATClient();
var log = new TwinCATCapturingLogger();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.ProbeCount >= 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // 3 ticks, no drops, no warning
fake.DroppedNotificationCount = 5;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
var ticks = fake.ProbeCount;
await WaitForAsync(() => fake.ProbeCount >= ticks + 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1); // 3 more ticks, no new drops, no repeat
fake.DroppedNotificationCount = 8;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
}
@@ -0,0 +1,225 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Native-subscribe isolation (#739): an unresolvable reference publishes <c>BadNodeIdUnknown</c>
/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not
/// take down the rest of the subscription (H11).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATNativeIsolationTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
/// <summary>
/// U12: an unknown reference and a tag routed to an unconfigured device each publish one
/// <c>BadNodeIdUnknown</c> snapshot; the resolvable tag still registers and delivers Good (positive control).
/// </summary>
[Fact]
public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt),
new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
var ghost = events.Single(e => e.FullReference == "Ghost");
ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
ghost.Snapshot.Value.ShouldBeNull();
events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
// Positive control: the resolvable tag registered and its push arrives Good.
factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed");
factory.Clients[0].FireNotification("MAIN.Speed", 42);
var good = events.Single(e => e.FullReference == "Speed");
good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
good.Snapshot.Value.ShouldBe(42);
}
/// <summary>
/// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and
/// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one
/// bad tag served zero data for the whole driver. The good tags keep delivering (positive control).
/// </summary>
[Fact]
public async Task One_failing_registration_does_not_tear_down_the_subscription()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true);
events.ShouldHaveSingleItem().FullReference.ShouldBe("B");
events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
fake.FireNotification("MAIN.C", 3);
events.Last().FullReference.ShouldBe("C");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// The failed tag is kept as a dead intent and unsubscribe still removes everything.
drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty();
fake.Notifications.ShouldBeEmpty();
}
/// <summary>
/// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own
/// status and leaves driver health alone; the good tag delivers.
/// </summary>
[Fact]
public async Task A_refused_symbol_is_isolated_without_a_health_write()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException(
"MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.FireNotification("MAIN.Good", 1);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents,
/// health Degraded); the reachable device's tags register and deliver.
/// </summary>
[Fact]
public async Task An_unreachable_device_blacks_out_only_its_own_tags()
{
const string hostB = "ads://5.23.91.24.1.1:851";
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// Device A's client is created first and works; device B's connect throws.
Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt),
new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true);
events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true);
events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Clients[0].FireNotification("MAIN.A2", 22);
events.Last().FullReference.ShouldBe("A2");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and
/// its first push then arrives Good — the subscription heals without a re-subscribe.
/// </summary>
[Fact]
public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(40),
Timeout = TimeSpan.FromMilliseconds(100),
},
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.AddNotificationErrors.Clear();
await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3));
fake.Notifications.Count.ShouldBe(1);
fake.FireNotification("MAIN.B", 5);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>A comms-class registration status stays an exception; any other status is a per-tag refusal.</summary>
[Fact]
public void Registration_failures_are_classified_by_status()
{
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown)
.ShouldBeOfType<TwinCATNotificationRefusedException>().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService)
.ShouldBeOfType<TwinCATNotificationRefusedException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError)
.ShouldBeOfType<InvalidOperationException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout)
.ShouldBeOfType<InvalidOperationException>();
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

Some files were not shown because too many files have changed in this diff Show More