Merge commit '5135a036' into remed/lane-S7
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"/>.
|
||||
|
||||
+6
@@ -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<object></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.");
|
||||
|
||||
|
||||
+56
-14
@@ -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>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+16
-2
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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>
|
||||
|
||||
+20
@@ -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, ","));
|
||||
}
|
||||
}
|
||||
|
||||
+172
@@ -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;
|
||||
}
|
||||
|
||||
+22
-2
@@ -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]
|
||||
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+149
@@ -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()
|
||||
|
||||
+218
@@ -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<<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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
Reference in New Issue
Block a user