Compare commits
40 Commits
master
...
38eb909f69
| Author | SHA1 | Date | |
|---|---|---|---|
| 38eb909f69 | |||
| d1699af609 | |||
| c6c694b69e | |||
| 4a3860ae92 | |||
| d57e24a7fa | |||
| bb1ab47b68 | |||
| a04ba2af7a | |||
| 494fdf2358 | |||
| 9f1e033e83 | |||
| fae00749ca | |||
| bf200e813e | |||
| 7209364c35 | |||
| 8314c273e7 | |||
| 1abf743a9f | |||
| 63a79791cd | |||
| cc757855e6 | |||
| 84913638b1 | |||
| 9ec92a9082 | |||
| 49fc23adc6 | |||
| 3c2c4f29ea | |||
| ae7cc15178 | |||
| 3d9697b918 | |||
| 329e222aa2 | |||
| 551494d223 | |||
| 5b4925e61a | |||
| 4ff4cc5899 | |||
| b95eaacc05 | |||
| c89f5bb3b9 | |||
| 07235d3b66 | |||
| f2bc36349e | |||
| ccf2e3a9c0 | |||
| 8f7265186d | |||
| 651d6c005c | |||
| 36b2929780 | |||
| 345ac97c43 | |||
| 767ac4aec5 | |||
| 29edd835a3 | |||
| d78a471e90 | |||
| 1d9e40236b | |||
| 2e6228a243 |
@@ -25,7 +25,7 @@ public enum DriverCapability
|
||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||
Discover,
|
||||
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
|
||||
Subscribe,
|
||||
|
||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||
|
||||
@@ -7,10 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <param name="State">Current driver-instance state.</param>
|
||||
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
||||
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
||||
/// <param name="Diagnostics">
|
||||
/// Optional driver-attributable counters/metrics surfaced for the <c>driver-diagnostics</c>
|
||||
/// RPC (introduced for Modbus task #154). Drivers populate the dictionary with stable,
|
||||
/// well-known keys (e.g. <c>PublishRequestCount</c>, <c>NotificationsPerSecond</c>);
|
||||
/// Core treats it as opaque metadata. Defaulted to an empty read-only dictionary so
|
||||
/// existing drivers and call-sites that don't construct this field stay back-compat.
|
||||
/// </param>
|
||||
public sealed record DriverHealth(
|
||||
DriverState State,
|
||||
DateTime? LastSuccessfulRead,
|
||||
string? LastError);
|
||||
string? LastError,
|
||||
IReadOnlyDictionary<string, double>? Diagnostics = null)
|
||||
{
|
||||
/// <summary>Driver-attributable counters, empty when the driver doesn't surface any.</summary>
|
||||
public IReadOnlyDictionary<string, double> DiagnosticsOrEmpty
|
||||
=> Diagnostics ?? EmptyDiagnostics;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyDiagnostics
|
||||
= new Dictionary<string, double>(0);
|
||||
}
|
||||
|
||||
/// <summary>Driver-instance lifecycle state.</summary>
|
||||
public enum DriverState
|
||||
|
||||
@@ -20,7 +20,29 @@ public interface ISubscribable
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
||||
/// <summary>
|
||||
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
|
||||
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
|
||||
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
|
||||
/// delegate to the simple
|
||||
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
|
||||
/// overload — the default implementation does exactly that, so existing implementers
|
||||
/// compile unchanged.
|
||||
/// </summary>
|
||||
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
|
||||
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
|
||||
Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<MonitoredTagSpec> tags,
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken)
|
||||
=> SubscribeAsync(
|
||||
tags.Select(t => t.TagName).ToList(),
|
||||
publishingInterval,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
|
||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -30,7 +52,7 @@ public interface ISubscribable
|
||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
}
|
||||
|
||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
|
||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
|
||||
public interface ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
|
||||
}
|
||||
|
||||
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
|
||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
|
||||
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
||||
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
||||
public sealed record DataChangeEventArgs(
|
||||
ISubscriptionHandle SubscriptionHandle,
|
||||
string FullReference,
|
||||
DataValueSnapshot Snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
|
||||
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
|
||||
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
|
||||
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
|
||||
/// <param name="SamplingIntervalMs">
|
||||
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
|
||||
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
|
||||
/// coalesce), useful for events that change between publish ticks.
|
||||
/// </param>
|
||||
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
|
||||
/// <param name="DiscardOldest">
|
||||
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
|
||||
/// <c>null</c> = driver default (true — preserve recency).
|
||||
/// </param>
|
||||
/// <param name="MonitoringMode">
|
||||
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
|
||||
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
|
||||
/// </param>
|
||||
/// <param name="DataChangeFilter">
|
||||
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
|
||||
/// (every change publishes regardless of magnitude).
|
||||
/// </param>
|
||||
public sealed record MonitoredTagSpec(
|
||||
string TagName,
|
||||
double? SamplingIntervalMs = null,
|
||||
uint? QueueSize = null,
|
||||
bool? DiscardOldest = null,
|
||||
SubscriptionMonitoringMode? MonitoringMode = null,
|
||||
DataChangeFilterSpec? DataChangeFilter = null);
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
|
||||
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
|
||||
/// as metadata even if they ignore the deadband mechanics.
|
||||
/// </summary>
|
||||
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
|
||||
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
|
||||
/// <param name="DeadbandValue">
|
||||
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
|
||||
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
|
||||
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
|
||||
/// </param>
|
||||
public sealed record DataChangeFilterSpec(
|
||||
DataChangeTrigger Trigger,
|
||||
DeadbandType DeadbandType,
|
||||
double DeadbandValue);
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
|
||||
/// leak an OPC-UA-stack reference into every driver project.
|
||||
/// </summary>
|
||||
public enum DataChangeTrigger
|
||||
{
|
||||
/// <summary>Fire only when StatusCode changes.</summary>
|
||||
Status = 0,
|
||||
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
|
||||
StatusValue = 1,
|
||||
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
|
||||
StatusValueTimestamp = 2,
|
||||
}
|
||||
|
||||
/// <summary>OPC UA deadband-filter modes.</summary>
|
||||
public enum DeadbandType
|
||||
{
|
||||
/// <summary>No deadband — every value change publishes.</summary>
|
||||
None = 0,
|
||||
/// <summary>Deadband expressed in the variable's engineering units.</summary>
|
||||
Absolute = 1,
|
||||
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
|
||||
Percent = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
|
||||
/// so Core.Abstractions stays SDK-free.
|
||||
/// </summary>
|
||||
public enum SubscriptionMonitoringMode
|
||||
{
|
||||
/// <summary>Item is created but neither sampling nor publishing.</summary>
|
||||
Disabled = 0,
|
||||
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
|
||||
Sampling = 1,
|
||||
/// <summary>Item samples and publishes — the OPC UA default.</summary>
|
||||
Reporting = 2,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
|
||||
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
|
||||
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
|
||||
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
|
||||
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
|
||||
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
|
||||
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
|
||||
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
|
||||
///
|
||||
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
|
||||
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
|
||||
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
|
||||
/// </remarks>
|
||||
public static class AbCipArrayReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
|
||||
/// <c>null</c> when the slice element type isn't supported under this declaration-only
|
||||
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
|
||||
/// scalar read path so the operator gets a clean per-element result instead.
|
||||
/// </summary>
|
||||
public static AbCipArrayReadPlan? TryBuild(
|
||||
AbCipTagDefinition definition,
|
||||
AbCipTagPath parsedPath,
|
||||
AbCipTagCreateParams baseParams)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
ArgumentNullException.ThrowIfNull(parsedPath);
|
||||
ArgumentNullException.ThrowIfNull(baseParams);
|
||||
if (parsedPath.Slice is null) return null;
|
||||
|
||||
if (!TryGetStride(definition.DataType, out var stride)) return null;
|
||||
|
||||
var slice = parsedPath.Slice;
|
||||
var createParams = baseParams with
|
||||
{
|
||||
TagName = parsedPath.ToLibplctagSliceArrayName(),
|
||||
ElementCount = slice.Count,
|
||||
};
|
||||
|
||||
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
|
||||
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
|
||||
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
|
||||
/// </summary>
|
||||
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
||||
var values = new object?[plan.Slice.Count];
|
||||
for (var i = 0; i < plan.Slice.Count; i++)
|
||||
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static bool TryGetStride(AbCipDataType type, out int stride)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||
stride = 1; return true;
|
||||
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||
stride = 2; return true;
|
||||
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||
stride = 4; return true;
|
||||
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||
case AbCipDataType.LReal:
|
||||
stride = 8; return true;
|
||||
default:
|
||||
stride = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan output: the libplctag create-params for the single array-read tag plus the
|
||||
/// element-type / stride / slice metadata the decoder needs.
|
||||
/// </summary>
|
||||
public sealed record AbCipArrayReadPlan(
|
||||
AbCipDataType ElementType,
|
||||
AbCipTagPathSlice Slice,
|
||||
int Stride,
|
||||
AbCipTagCreateParams CreateParams);
|
||||
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
|
||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
|
||||
AbCipDataType.LInt => DriverDataType.Int64,
|
||||
AbCipDataType.ULInt => DriverDataType.UInt64,
|
||||
AbCipDataType.Real => DriverDataType.Float32,
|
||||
AbCipDataType.LReal => DriverDataType.Float64,
|
||||
AbCipDataType.String => DriverDataType.String,
|
||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
||||
AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
|
||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent);
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
StringLength: member.StringLength);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
@@ -357,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return;
|
||||
}
|
||||
|
||||
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
|
||||
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
|
||||
// Rockwell array read; the contiguous buffer is decoded at element stride into a
|
||||
// single snapshot whose Value is an object[] of the N elements.
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
if (parsedPath?.Slice is not null)
|
||||
{
|
||||
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
@@ -372,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var bitIndex = parsedPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
@@ -390,6 +401,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
|
||||
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
|
||||
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
|
||||
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
|
||||
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
|
||||
/// </summary>
|
||||
private async Task ReadSliceAsync(
|
||||
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
|
||||
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var baseParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsedPath.ToLibplctagName(),
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
|
||||
if (plan is null)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
|
||||
.ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading slice {def.Name}");
|
||||
return;
|
||||
}
|
||||
|
||||
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
|
||||
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
|
||||
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
|
||||
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(createParams);
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[tagName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
@@ -451,65 +545,103 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||||
/// plan decisions #44, #45, #143 the caller opts in via
|
||||
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||||
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||||
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||||
/// errors as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
|
||||
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
|
||||
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
|
||||
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
|
||||
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
|
||||
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
|
||||
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
|
||||
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
|
||||
/// preserved in the caller's input order on partial failures.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
var plans = AbCipMultiWritePlanner.Build(
|
||||
writes, _tagsByName, _devices,
|
||||
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||||
{
|
||||
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bit-RMW writes always serialise per-parent — never packed.
|
||||
foreach (var entry in plan.BitRmw)
|
||||
results[entry.OriginalIndex] = new WriteResult(
|
||||
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
if (plan.Packable.Count == 0) continue;
|
||||
|
||||
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||||
{
|
||||
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
|
||||
// into one Multi-Service Packet when the family supports it.
|
||||
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
|
||||
for (var i = 0; i < plan.Packable.Count; i++)
|
||||
{
|
||||
var entry = plan.Packable[i];
|
||||
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||||
}
|
||||
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
foreach (var (idx, code) in outcomes)
|
||||
results[idx] = new WriteResult(code);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||||
foreach (var entry in plan.Packable)
|
||||
{
|
||||
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
|
||||
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
|
||||
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
|
||||
/// </summary>
|
||||
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
|
||||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||
{
|
||||
var def = entry.Definition;
|
||||
var w = entry.Request;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
if (status == 0)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||
}
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -517,32 +649,78 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
/// <summary>
|
||||
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
|
||||
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
|
||||
/// packed because two concurrent writes against the same parent DINT would race their
|
||||
/// read-modify-write windows.
|
||||
/// </summary>
|
||||
private async Task<uint> ExecuteBitRmwWriteAsync(
|
||||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bit = entry.ParsedPath!.BitIndex!.Value;
|
||||
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (code == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
return code;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return AbCipStatusMapper.BadNotSupported;
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
return AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
return AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
return AbCipStatusMapper.BadOutOfRange;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return AbCipStatusMapper.BadCommunicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -633,7 +811,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
Timeout: _options.Timeout,
|
||||
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -92,6 +92,13 @@ public sealed record AbCipDeviceOptions(
|
||||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||
/// write attempt failing at runtime.</param>
|
||||
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
|
||||
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
|
||||
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
|
||||
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
|
||||
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
|
||||
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
|
||||
/// non-<see cref="AbCipDataType.String"/> types.</param>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition(
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
bool SafetyTag = false,
|
||||
int? StringLength = null);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
@@ -112,7 +120,8 @@ public sealed record AbCipStructureMember(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? StringLength = null);
|
||||
|
||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||
public enum AbCipPlcFamily
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
|
||||
/// device so the driver can submit one round of writes per device instead of looping
|
||||
/// strictly serially across the whole batch. Honours the per-family
|
||||
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
|
||||
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
|
||||
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
|
||||
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
|
||||
/// not surface Multi-Service Packet construction at the API surface — but the underlying
|
||||
/// native library packs concurrent operations against the same connection automatically
|
||||
/// when the family's protocol supports it. Issuing the writes concurrently per device
|
||||
/// therefore gives us the round-trip reduction described in #228 without having to drop to
|
||||
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
|
||||
/// unsafe.</para>
|
||||
///
|
||||
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
|
||||
/// packing here because they need a serialised read-modify-write under the per-parent
|
||||
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
|
||||
/// on the same DINT would risk losing one another's update.</para>
|
||||
/// </remarks>
|
||||
internal static class AbCipMultiWritePlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
|
||||
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
|
||||
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
|
||||
/// when the device supports packing.
|
||||
/// </summary>
|
||||
internal readonly record struct ClassifiedWrite(
|
||||
int OriginalIndex,
|
||||
WriteRequest Request,
|
||||
AbCipTagDefinition Definition,
|
||||
AbCipTagPath? ParsedPath,
|
||||
bool IsBitRmw);
|
||||
|
||||
/// <summary>
|
||||
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
|
||||
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
|
||||
/// DINT.
|
||||
/// </summary>
|
||||
internal sealed class DevicePlan
|
||||
{
|
||||
public required string DeviceHostAddress { get; init; }
|
||||
public required AbCipPlcFamilyProfile Profile { get; init; }
|
||||
public List<ClassifiedWrite> Packable { get; } = new();
|
||||
public List<ClassifiedWrite> BitRmw { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-device plan list. Entries are visited in input order so the resulting
|
||||
/// plan's traversal preserves caller ordering within each device. Entries that fail
|
||||
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
|
||||
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
|
||||
/// the plan.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DevicePlan> Build(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
|
||||
Action<int, uint> reportPreflight)
|
||||
{
|
||||
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
|
||||
var order = new List<DevicePlan>();
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
|
||||
{
|
||||
plan = new DevicePlan
|
||||
{
|
||||
DeviceHostAddress = def.DeviceHostAddress,
|
||||
Profile = device.Profile,
|
||||
};
|
||||
plans[def.DeviceHostAddress] = plan;
|
||||
order.Add(plan);
|
||||
}
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath);
|
||||
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
|
||||
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
|
||||
if (isBitRmw) plan.BitRmw.Add(entry);
|
||||
else plan.Packable.Add(entry);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public sealed record AbCipTagPath(
|
||||
string? ProgramScope,
|
||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||
int? BitIndex)
|
||||
int? BitIndex,
|
||||
AbCipTagPathSlice? Slice = null)
|
||||
{
|
||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||
public string ToLibplctagName()
|
||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
||||
if (seg.Subscripts.Count > 0)
|
||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||
}
|
||||
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
|
||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||
return buf.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
|
||||
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
|
||||
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
|
||||
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
|
||||
/// </summary>
|
||||
public string ToLibplctagSliceArrayName()
|
||||
{
|
||||
if (Slice is null) return ToLibplctagName();
|
||||
var buf = new System.Text.StringBuilder();
|
||||
if (ProgramScope is not null)
|
||||
buf.Append("Program:").Append(ProgramScope).Append('.');
|
||||
|
||||
for (var i = 0; i < Segments.Count; i++)
|
||||
{
|
||||
if (i > 0) buf.Append('.');
|
||||
var seg = Segments[i];
|
||||
buf.Append(seg.Name);
|
||||
if (seg.Subscripts.Count > 0)
|
||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||
}
|
||||
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
|
||||
// "read N consecutive elements starting at index 0", which is the exact Rockwell
|
||||
// array-read semantic this PR is wiring up.
|
||||
buf.Append('[').Append(Slice.Start).Append(']');
|
||||
return buf.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
||||
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
||||
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
|
||||
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
||||
foreach (var part in parts)
|
||||
AbCipTagPathSlice? slice = null;
|
||||
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||
{
|
||||
var part = parts[partIdx];
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
||||
var name = part[..bracketIdx];
|
||||
if (!IsValidIdent(name)) return null;
|
||||
var inner = part[(bracketIdx + 1)..^1];
|
||||
|
||||
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
|
||||
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
|
||||
// Any other shape is rejected so callers see a config-validation error rather than
|
||||
// the driver attempting a best-effort scalar read.
|
||||
if (inner.Contains(".."))
|
||||
{
|
||||
if (partIdx != parts.Count - 1) return null; // slice + sub-element
|
||||
if (bitIndex is not null) return null; // slice + bit index
|
||||
if (inner.Contains(',')) return null; // slice cannot be multi-dim
|
||||
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
|
||||
if (parts2.Length != 2) return null;
|
||||
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
|
||||
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
|
||||
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
|
||||
segments.Add(new AbCipTagPathSegment(name, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
var subs = new List<int>();
|
||||
foreach (var tok in inner.Split(','))
|
||||
{
|
||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
|
||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
||||
return new AbCipTagPath(programScope, segments, bitIndex, slice);
|
||||
}
|
||||
|
||||
private static bool IsValidIdent(string s)
|
||||
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
|
||||
|
||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||
|
||||
/// <summary>
|
||||
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
|
||||
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
|
||||
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
|
||||
/// Rockwell array read covering <c>End - Start + 1</c> elements.
|
||||
/// </summary>
|
||||
public sealed record AbCipTagPathSlice(int Start, int End)
|
||||
{
|
||||
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
|
||||
public int Count => End - Start + 1;
|
||||
}
|
||||
|
||||
@@ -65,10 +65,20 @@ public interface IAbCipTagFactory
|
||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
|
||||
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
|
||||
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
|
||||
/// behaviour for back-compat.</param>
|
||||
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
|
||||
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
|
||||
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
|
||||
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
|
||||
public sealed record AbCipTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
TimeSpan Timeout,
|
||||
int? StringMaxCapacity = null,
|
||||
int? ElementCount = null);
|
||||
|
||||
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
|
||||
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
|
||||
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
|
||||
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
|
||||
if (p.StringMaxCapacity is int cap && cap > 0)
|
||||
_tag.StringMaxCapacity = (uint)cap;
|
||||
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
|
||||
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
|
||||
// to issue one Rockwell array read for a [N..M] slice.
|
||||
if (p.ElementCount is int n && n > 0)
|
||||
_tag.ElementCount = n;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||
AbCipDataType.Structure => null,
|
||||
_ => null,
|
||||
};
|
||||
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbCipDataType.Dt:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
string? SubElement,
|
||||
AbLegacyAddress? IndirectFileSource = null,
|
||||
AbLegacyAddress? IndirectWordSource = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when either the file number or the word number is sourced from another PCCC
|
||||
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
|
||||
/// so the runtime layer must resolve the inner address first and rewrite the tag name
|
||||
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||
// accept the bracket form directly — callers that need a libplctag-ready name must
|
||||
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
|
||||
// path (TODO: resolve-then-read) is gated on IsIndirect.
|
||||
string filePart;
|
||||
if (IndirectFileSource is not null)
|
||||
{
|
||||
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
}
|
||||
|
||||
string wordSegment = IndirectWordSource is not null
|
||||
? $"[{IndirectWordSource.ToLibplctagName()}]"
|
||||
: WordNumber.ToString();
|
||||
|
||||
var wordPart = $"{filePart}:{wordSegment}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
||||
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
||||
/// Pass the device's family so the parser can interpret those digits as octal when the
|
||||
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
||||
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||
/// is what libplctag's PCCC layer expects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
||||
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
||||
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
||||
/// </remarks>
|
||||
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||
|
||||
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||
string? bitText = null;
|
||||
var slashIdx = src.LastIndexOf('/');
|
||||
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
return ParseTail(src, bitText, profile, allowIndirect: true);
|
||||
}
|
||||
|
||||
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
|
||||
{
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
|
||||
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
@@ -69,29 +123,139 @@ public sealed record AbLegacyAddress(
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
var colonIdx = IndexOfTopLevel(src, ':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
||||
|
||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||
// File letter (always literal) + optional file number — either decimal digits or a
|
||||
// bracketed indirect address like N[N7:0].
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
AbLegacyAddress? indirectFile = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
var fileTail = filePart[letterEnd..];
|
||||
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
var inner = fileTail[1..^1];
|
||||
indirectFile = ParseInner(inner, profile);
|
||||
if (indirectFile is null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||
if (!IsKnownFileLetter(letter)) return null;
|
||||
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
||||
if (!IsKnownFileLetter(letter))
|
||||
{
|
||||
if (!IsFunctionFileLetter(letter) || profile?.SupportsFunctionFiles != true) return null;
|
||||
}
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
||||
|
||||
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
|
||||
// indirect address.
|
||||
int word = 0;
|
||||
AbLegacyAddress? indirectWord = null;
|
||||
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
var inner = wordPart[1..^1];
|
||||
indirectWord = ParseInner(inner, profile);
|
||||
if (indirectWord is null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
|
||||
}
|
||||
|
||||
int? bitIndex = null;
|
||||
if (bitText is not null)
|
||||
{
|
||||
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
}
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
||||
/// must NOT be indirect — nesting beyond one level is rejected.
|
||||
/// </summary>
|
||||
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inner)) return null;
|
||||
var src = inner.Trim();
|
||||
// Reject any further bracket — depth cap at 1.
|
||||
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
|
||||
|
||||
string? bitText = null;
|
||||
var slashIdx = src.LastIndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
return ParseTail(src, bitText, profile, allowIndirect: false);
|
||||
}
|
||||
|
||||
private static int IndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int LastIndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
var last = -1;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) last = i;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private static bool TryParseIndex(string text, bool octal, out int value)
|
||||
{
|
||||
if (octal)
|
||||
{
|
||||
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
|
||||
if (text.Length == 0) { value = 0; return false; }
|
||||
var start = 0;
|
||||
var sign = 1;
|
||||
if (text[0] == '-') { sign = -1; start = 1; }
|
||||
if (start >= text.Length) { value = 0; return false; }
|
||||
var acc = 0;
|
||||
for (var i = start; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (c < '0' || c > '7') { value = 0; return false; }
|
||||
acc = (acc * 8) + (c - '0');
|
||||
}
|
||||
value = sign * acc;
|
||||
return true;
|
||||
}
|
||||
return int.TryParse(text, out value);
|
||||
}
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
@@ -99,4 +263,14 @@ public sealed record AbLegacyAddress(
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
|
||||
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
|
||||
/// </summary>
|
||||
internal static bool IsFunctionFileLetter(string letter) => letter switch
|
||||
{
|
||||
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,72 @@ public enum AbLegacyDataType
|
||||
CounterElement,
|
||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||
ControlElement,
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
|
||||
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
|
||||
/// </summary>
|
||||
MicroLogixFunctionFile,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
|
||||
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
|
||||
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
|
||||
/// boundary so the driver never refuses a tag the customer happens to know about.
|
||||
/// </summary>
|
||||
public static class AbLegacyFunctionFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
|
||||
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
|
||||
/// the driver permissive without forcing every quirk into the catalogue.
|
||||
/// </summary>
|
||||
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||
{
|
||||
if (subElement is null) return DriverDataType.Int32;
|
||||
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
|
||||
return key switch
|
||||
{
|
||||
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
|
||||
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
|
||||
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
|
||||
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||
|
||||
// High-speed counter — accumulator/preset are Int32, status flags are bits.
|
||||
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
|
||||
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or
|
||||
("HSC", "IN") or ("HSC", "IH") or ("HSC", "IL") or
|
||||
("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
|
||||
|
||||
// Daylight saving + memory module info.
|
||||
("DLS", "STR") or ("DLS", "STD") => DriverDataType.Int32,
|
||||
("DLS", "EN") => DriverDataType.Boolean,
|
||||
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
|
||||
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||
|
||||
// Pulse-train / PWM output blocks.
|
||||
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
|
||||
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
|
||||
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
|
||||
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or
|
||||
("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
|
||||
|
||||
// Selectable timed interrupt + event input interrupt.
|
||||
("STI", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
|
||||
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
|
||||
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
|
||||
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
|
||||
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||
|
||||
// I/O status + base hardware info — mostly status flags + a few counters.
|
||||
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
|
||||
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
|
||||
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
@@ -40,6 +106,106 @@ public static class AbLegacyDataTypeExtensions
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
|
||||
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
|
||||
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
|
||||
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
|
||||
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
|
||||
/// </summary>
|
||||
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return t.ToDriverDataType();
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
AbLegacyDataType.TimerElement => key switch
|
||||
{
|
||||
"EN" or "TT" or "DN" => DriverDataType.Boolean,
|
||||
"PRE" or "ACC" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
AbLegacyDataType.CounterElement => key switch
|
||||
{
|
||||
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
|
||||
"PRE" or "ACC" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
AbLegacyDataType.ControlElement => key switch
|
||||
{
|
||||
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
|
||||
"LEN" or "POS" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
_ => t.ToDriverDataType(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bit position within the parent control word for Timer/Counter/Control status bits.
|
||||
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
|
||||
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
|
||||
/// </summary>
|
||||
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return null;
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
|
||||
AbLegacyDataType.TimerElement => key switch
|
||||
{
|
||||
"DN" => 13,
|
||||
"TT" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
|
||||
AbLegacyDataType.CounterElement => key switch
|
||||
{
|
||||
"UN" => 10,
|
||||
"OV" => 11,
|
||||
"DN" => 12,
|
||||
"CD" => 13,
|
||||
"CU" => 14,
|
||||
_ => null,
|
||||
},
|
||||
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
|
||||
AbLegacyDataType.ControlElement => key switch
|
||||
{
|
||||
"FD" => 8,
|
||||
"IN" => 9,
|
||||
"UL" => 10,
|
||||
"ER" => 11,
|
||||
"EM" => 12,
|
||||
"DN" => 13,
|
||||
"EU" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
|
||||
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
|
||||
/// omitted so they keep default writable behaviour.
|
||||
/// </summary>
|
||||
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return false;
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
AbLegacyDataType.TimerElement => key is "DN" or "TT",
|
||||
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
|
||||
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
// Timer/Counter/Control status bits route through GetBit at the parent-word
|
||||
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
|
||||
// and pass it down to the runtime as a synthetic bitIndex.
|
||||
var decodeBit = parsed?.BitIndex
|
||||
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
|
||||
var value = runtime.DecodeValue(def.DataType, decodeBit);
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
|
||||
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
|
||||
// IN) are read-only — the PLC sets them; any client write would be silently
|
||||
// overwritten on the next scan. Reject up front with BadNotWritable.
|
||||
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||
@@ -247,12 +261,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
|
||||
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
|
||||
// base element's Int32 to Boolean for status bits and Int32 for word members.
|
||||
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
|
||||
tag.DataType, parsed?.SubElement);
|
||||
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
||||
tag.DataType, parsed?.SubElement);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
DriverDataType: effectiveType,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
SecurityClass: tag.Writable && !plcSetBit
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
@@ -413,10 +434,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
|
||||
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
|
||||
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
|
||||
// rewriting the tag name with the resolved word number, then issuing the actual read.
|
||||
// For now we surface a clear runtime error rather than letting libplctag fail with an
|
||||
// opaque parser error.
|
||||
if (parsed.IsIndirect)
|
||||
throw new NotSupportedException(
|
||||
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -40,8 +40,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||
AbLegacyDataType.String => _tag.GetString(0),
|
||||
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
|
||||
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
|
||||
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
|
||||
// decode as Int32 like before.
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
||||
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
|
||||
? _tag.GetBit(statusBit)
|
||||
: _tag.GetInt32(0),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
string DefaultCipPath,
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
bool SupportsLongFile,
|
||||
bool OctalIoAddressing,
|
||||
bool SupportsFunctionFiles)
|
||||
{
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
@@ -25,21 +27,27 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||
SupportsLongFile: true); // L file available SLC 5/05+
|
||||
SupportsLongFile: true, // L file available SLC 5/05+
|
||||
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
|
||||
SupportsFunctionFiles: false); // SLC500 has no function files
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||
LibplctagPlcAttribute: "micrologix",
|
||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||
MaxTagBytes: 232,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
||||
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
|
||||
SupportsFunctionFiles: true); // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||
LibplctagPlcAttribute: "plc5",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // PLC-5 predates L files
|
||||
SupportsLongFile: false, // PLC-5 predates L files
|
||||
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
|
||||
SupportsFunctionFiles: false);
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
@@ -51,7 +59,9 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: true);
|
||||
SupportsLongFile: true,
|
||||
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
|
||||
SupportsFunctionFiles: false);
|
||||
}
|
||||
|
||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||
|
||||
@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
|
||||
_ => int.MaxValue,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
|
||||
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
|
||||
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
|
||||
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
|
||||
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
|
||||
/// stays permissive (matches the modal / override fixed-tree precedent in
|
||||
/// issue #259). Issue #260.
|
||||
/// </summary>
|
||||
public static bool SupportsTooling(FocasCncSeries series) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
|
||||
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
|
||||
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
|
||||
/// policy as <see cref="SupportsTooling"/>. Issue #260.
|
||||
/// </summary>
|
||||
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
|
||||
|
||||
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||
{
|
||||
var (min, max) = MacroRange(series);
|
||||
|
||||
@@ -24,8 +24,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _toolingNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _messagesNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _currentBlockNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
|
||||
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
|
||||
/// deterministic discovery output.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusFieldNames =
|
||||
[
|
||||
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
||||
/// seconds (issue #258). Order matters for deterministic discovery output.
|
||||
/// </summary>
|
||||
private static readonly string[] ProductionFieldNames =
|
||||
[
|
||||
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
|
||||
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
|
||||
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
|
||||
/// </summary>
|
||||
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the four operator-override child nodes per device — Feed / Rapid /
|
||||
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
|
||||
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
|
||||
/// given field has the matching node omitted from the address space.
|
||||
/// </summary>
|
||||
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the standard work-coordinate offset slots surfaced under
|
||||
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
|
||||
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
|
||||
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
|
||||
/// </summary>
|
||||
private static readonly string[] WorkOffsetSlotNames =
|
||||
[
|
||||
"G54", "G55", "G56", "G57", "G58", "G59",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
|
||||
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
|
||||
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
|
||||
/// </summary>
|
||||
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
|
||||
/// counters surfaced for operator visibility (issue #262). Order matters for
|
||||
/// deterministic discovery output.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
|
||||
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
|
||||
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
|
||||
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
|
||||
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static readonly string[] DiagnosticsFieldNames =
|
||||
[
|
||||
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
|
||||
];
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -76,6 +164,67 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByName[tag.Name] = tag;
|
||||
}
|
||||
|
||||
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
|
||||
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
|
||||
foreach (var device in _devices.Values)
|
||||
{
|
||||
foreach (var field in StatusFieldNames)
|
||||
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
foreach (var field in ProductionFieldNames)
|
||||
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
foreach (var field in ModalFieldNames)
|
||||
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
if (device.Options.OverrideParameters is { } op)
|
||||
{
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(op, field) is null) continue;
|
||||
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
}
|
||||
|
||||
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
|
||||
// life + active offset index are deferred per the F1-d plan; they need
|
||||
// ODBTLIFE* unions whose shape varies per series.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
|
||||
{
|
||||
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
|
||||
device.Options.HostAddress;
|
||||
}
|
||||
|
||||
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
|
||||
// coordinate offsets (issue #260). Capability matrix gates by series so
|
||||
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
|
||||
{
|
||||
foreach (var slot in WorkOffsetSlotNames)
|
||||
foreach (var axis in WorkOffsetAxisNames)
|
||||
{
|
||||
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
|
||||
(device.Options.HostAddress, slot, axis);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
|
||||
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
|
||||
// tick (issue #261). Permissive across series (no capability gate yet).
|
||||
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
|
||||
// Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage,
|
||||
// LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for
|
||||
// operator visibility (issue #262). Permissive across all CNC series.
|
||||
foreach (var field in DiagnosticsFieldNames)
|
||||
_diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -113,6 +262,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_statusNodesByName.Clear();
|
||||
_productionNodesByName.Clear();
|
||||
_modalNodesByName.Clear();
|
||||
_overrideNodesByName.Clear();
|
||||
_toolingNodesByName.Clear();
|
||||
_offsetNodesByName.Clear();
|
||||
_messagesNodesByName.Clear();
|
||||
_currentBlockNodesByName.Clear();
|
||||
_diagnosticsNodesByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -136,6 +294,73 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
|
||||
// refreshed on the probe tick (issue #257). No wire call here.
|
||||
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
|
||||
{
|
||||
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Production/ nodes — served from the per-device cached production
|
||||
// snapshot refreshed on the probe tick (issue #258). No wire call here.
|
||||
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
|
||||
{
|
||||
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
|
||||
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
|
||||
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
|
||||
{
|
||||
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
|
||||
{
|
||||
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
|
||||
// refreshed on the probe tick (issue #260). No wire call here.
|
||||
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
|
||||
{
|
||||
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
|
||||
// snapshot refreshed on the probe tick (issue #260). No wire call here.
|
||||
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
|
||||
{
|
||||
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
|
||||
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
|
||||
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
|
||||
{
|
||||
results[i] = ReadMessagesLatestField(messagesHost, now);
|
||||
continue;
|
||||
}
|
||||
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
|
||||
{
|
||||
results[i] = ReadCurrentBlockField(blockHost, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe
|
||||
// loop (issue #262). No wire call here.
|
||||
if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey))
|
||||
{
|
||||
results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -257,10 +482,267 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
|
||||
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
|
||||
var statusFolder = deviceFolder.Folder("Status", "Status");
|
||||
foreach (var field in StatusFieldNames)
|
||||
{
|
||||
var fullRef = StatusReferenceFor(device.HostAddress, field);
|
||||
statusFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
|
||||
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
|
||||
// + served from DeviceState.LastProduction.
|
||||
var productionFolder = deviceFolder.Folder("Production", "Production");
|
||||
foreach (var field in ProductionFieldNames)
|
||||
{
|
||||
var fullRef = ProductionReferenceFor(device.HostAddress, field);
|
||||
productionFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int32,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
|
||||
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
|
||||
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
|
||||
// per series + group (issue #259, plan PR F1-c).
|
||||
var modalFolder = deviceFolder.Folder("Modal", "Modal");
|
||||
foreach (var field in ModalFieldNames)
|
||||
{
|
||||
var fullRef = ModalReferenceFor(device.HostAddress, field);
|
||||
modalFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
|
||||
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
|
||||
// OverrideParameters is null; per-field nodes whose parameter is null are
|
||||
// omitted so a deployment can hide overrides their MTB doesn't wire up.
|
||||
if (device.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var overrideFolder = deviceFolder.Folder("Override", "Override");
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(overrideParams, field) is null) continue;
|
||||
var fullRef = OverrideReferenceFor(device.HostAddress, field);
|
||||
overrideFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
|
||||
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
|
||||
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
|
||||
{
|
||||
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
|
||||
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
|
||||
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
|
||||
FullName: toolingRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
|
||||
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
|
||||
// by series so legacy controllers without cnc_rdzofs support don't expose
|
||||
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
|
||||
{
|
||||
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
|
||||
foreach (var slot in WorkOffsetSlotNames)
|
||||
{
|
||||
var slotFolder = offsetsFolder.Folder(slot, slot);
|
||||
foreach (var axis in WorkOffsetAxisNames)
|
||||
{
|
||||
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
|
||||
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest — single String node per device backed
|
||||
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
|
||||
// The issue body permits this minimal "latest message" surface in the first
|
||||
// cut over a full ring-buffer of all four slots.
|
||||
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
|
||||
var externalFolder = messagesFolder.Folder("External", "External");
|
||||
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
|
||||
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
|
||||
FullName: messagesRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
|
||||
// Fixed-tree Program/CurrentBlock — single String node per device backed by
|
||||
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
|
||||
var programFolder = deviceFolder.Folder("Program", "Program");
|
||||
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
|
||||
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
|
||||
FullName: blockRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
|
||||
// Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for
|
||||
// operator visibility (issue #262). ReadCount / ReadFailureCount /
|
||||
// ReconnectCount are Int64; LastErrorMessage is String;
|
||||
// LastSuccessfulRead is DateTime. Permissive across CNC series — every
|
||||
// device gets the same shape.
|
||||
var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics");
|
||||
foreach (var field in DiagnosticsFieldNames)
|
||||
{
|
||||
var fullRef = DiagnosticsReferenceFor(device.HostAddress, field);
|
||||
diagnosticsFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DiagnosticsFieldType(field),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DriverDataType DiagnosticsFieldType(string field) => field switch
|
||||
{
|
||||
"ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64,
|
||||
"LastErrorMessage" => DriverDataType.String,
|
||||
"LastSuccessfulRead" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
|
||||
private static string StatusReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Status/{field}";
|
||||
|
||||
private static string ProductionReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Production/{field}";
|
||||
|
||||
private static string ModalReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Modal/{field}";
|
||||
|
||||
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Override/{field}";
|
||||
|
||||
private static string ToolingReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Tooling/{field}";
|
||||
|
||||
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
|
||||
$"{hostAddress}::Offsets/{slot}/{axis}";
|
||||
|
||||
private static string MessagesLatestReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Messages/External/Latest";
|
||||
|
||||
private static string CurrentBlockReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Program/CurrentBlock";
|
||||
|
||||
private static string DiagnosticsReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Diagnostics/{field}";
|
||||
|
||||
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
||||
{
|
||||
"Feed" => p.FeedParam,
|
||||
"Rapid" => p.RapidParam,
|
||||
"Spindle" => p.SpindleParam,
|
||||
"Jog" => p.JogParam,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||
{
|
||||
"Tmmode" => s.Tmmode,
|
||||
"Aut" => s.Aut,
|
||||
"Run" => s.Run,
|
||||
"Motion" => s.Motion,
|
||||
"Mstb" => s.Mstb,
|
||||
"EmergencyStop" => s.EmergencyStop,
|
||||
"Alarm" => s.Alarm,
|
||||
"Edit" => s.Edit,
|
||||
"Dummy" => s.Dummy,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
|
||||
{
|
||||
"PartsProduced" => p.PartsProduced,
|
||||
"PartsRequired" => p.PartsRequired,
|
||||
"PartsTotal" => p.PartsTotal,
|
||||
"CycleTimeSeconds" => p.CycleTimeSeconds,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickModalField(FocasModalInfo m, string field) => field switch
|
||||
{
|
||||
"MCode" => m.MCode,
|
||||
"SCode" => m.SCode,
|
||||
"TCode" => m.TCode,
|
||||
"BCode" => m.BCode,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
|
||||
{
|
||||
"Feed" => o.Feed,
|
||||
"Rapid" => o.Rapid,
|
||||
"Spindle" => o.Spindle,
|
||||
"Jog" => o.Jog,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -283,13 +765,123 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
string? failureMessage = null;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
if (success)
|
||||
{
|
||||
// Refresh figure-scaling cache once per session (issue #262). The
|
||||
// increment system rarely changes mid-session; re-reading every probe
|
||||
// tick would waste a wire call. Best-effort — null result leaves the
|
||||
// previous good map in place.
|
||||
if (state.FigureScaling is null)
|
||||
{
|
||||
var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false);
|
||||
if (fig is not null) state.FigureScaling = fig;
|
||||
}
|
||||
|
||||
// Refresh the cached ODBST status snapshot on every probe tick — this is
|
||||
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
|
||||
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
|
||||
// unchanged so the previous good snapshot keeps serving until refreshed.
|
||||
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
state.LastStatus = snapshot;
|
||||
state.LastStatusUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Refresh the cached production snapshot too — same best-effort policy
|
||||
// as Status/: a null result leaves the previous good snapshot in place
|
||||
// so reads keep serving until the next successful refresh (issue #258).
|
||||
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
|
||||
if (production is not null)
|
||||
{
|
||||
state.LastProduction = production;
|
||||
state.LastProductionUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
|
||||
// policy as Status/ + Production/. Override snapshot is suppressed when
|
||||
// the device has no OverrideParameters configured (issue #259).
|
||||
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
|
||||
if (modal is not null)
|
||||
{
|
||||
state.LastModal = modal;
|
||||
state.LastModalUtc = DateTime.UtcNow;
|
||||
}
|
||||
if (state.Options.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
|
||||
if (ov is not null)
|
||||
{
|
||||
state.LastOverride = ov;
|
||||
state.LastOverrideUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
|
||||
// effort policy as the other fixed-tree caches (issue #260). A
|
||||
// null result leaves the previous good snapshot in place so reads
|
||||
// keep serving until the next successful refresh.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
|
||||
{
|
||||
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
|
||||
if (tooling is not null)
|
||||
{
|
||||
state.LastTooling = tooling;
|
||||
state.LastToolingUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
|
||||
{
|
||||
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
|
||||
if (offsets is not null)
|
||||
{
|
||||
state.LastWorkOffsets = offsets;
|
||||
state.LastWorkOffsetsUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Operator messages + currently-executing block — same best-effort
|
||||
// policy as the other fixed-tree caches (issue #261). A null result
|
||||
// leaves the previous good snapshot in place so reads keep serving
|
||||
// until the next successful refresh.
|
||||
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
|
||||
if (messages is not null)
|
||||
{
|
||||
state.LastMessages = messages;
|
||||
state.LastMessagesUtc = DateTime.UtcNow;
|
||||
}
|
||||
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
|
||||
if (block is not null)
|
||||
{
|
||||
state.LastCurrentBlock = block;
|
||||
state.LastCurrentBlockUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureMessage = ex.Message;
|
||||
/* connect-failure path already disposed + cleared the client */
|
||||
}
|
||||
|
||||
// Diagnostics counters refreshed per probe tick (issue #262). Successful
|
||||
// ticks bump ReadCount + LastSuccessfulRead; failed ticks bump
|
||||
// ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in
|
||||
// EnsureConnectedAsync's connect path so a wedged probe doesn't double-count.
|
||||
if (success)
|
||||
{
|
||||
Interlocked.Increment(ref state.ReadCount);
|
||||
state.LastSuccessfulReadUtc = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref state.ReadFailureCount);
|
||||
if (!string.IsNullOrEmpty(failureMessage))
|
||||
state.LastErrorMessage = failureMessage;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
@@ -298,6 +890,161 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastStatus is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickStatusField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastStatusUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastProduction is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickProductionField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
|
||||
device.LastProductionUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastModal is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickModalField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastModalUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastOverride is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickOverrideField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastOverrideUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastTooling is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
return field switch
|
||||
{
|
||||
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
|
||||
device.LastToolingUtc, now),
|
||||
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
|
||||
};
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastWorkOffsets is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var match = snap.Offsets.FirstOrDefault(o =>
|
||||
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
var value = axis switch
|
||||
{
|
||||
"X" => (double?)match.X,
|
||||
"Y" => match.Y,
|
||||
"Z" => match.Z,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
|
||||
device.LastWorkOffsetsUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastMessages is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
|
||||
// (most-recent) entry — the issue body permits this minimal "latest message"
|
||||
// surface in lieu of a full ring buffer of all 4 classes.
|
||||
var latest = snap.Messages.Count == 0
|
||||
? string.Empty
|
||||
: snap.Messages[snap.Messages.Count - 1].Text;
|
||||
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
|
||||
device.LastMessagesUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastCurrentBlock is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
|
||||
device.LastCurrentBlockUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
// Diagnostics counters are always Good — they're driver-internal state, not wire
|
||||
// reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe
|
||||
// tick rather than null because OPC UA's DateTime variant has no "unset" sentinel
|
||||
// a generic client can interpret (issue #262).
|
||||
object? value = field switch
|
||||
{
|
||||
"ReadCount" => Interlocked.Read(ref device.ReadCount),
|
||||
"ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount),
|
||||
"ReconnectCount" => Interlocked.Read(ref device.ReconnectCount),
|
||||
"LastErrorMessage" => device.LastErrorMessage ?? string.Empty,
|
||||
"LastSuccessfulRead" => device.LastSuccessfulReadUtc,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
|
||||
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
|
||||
/// device has a cached scaling entry for <paramref name="axisName"/> AND
|
||||
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
|
||||
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
|
||||
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
|
||||
/// don't need to re-derive the policy (issue #262).
|
||||
/// </summary>
|
||||
internal double ApplyFigureScaling(string hostAddress, string axisName, long raw)
|
||||
{
|
||||
if (!_options.FixedTree.ApplyFigureScaling) return raw;
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) return raw;
|
||||
if (device.FigureScaling is not { } map) return raw;
|
||||
if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw;
|
||||
return raw / Math.Pow(10.0, dec);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -324,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is { IsConnected: true } c) return c;
|
||||
// Reconnect counter bumps before the connect call — a successful first connect
|
||||
// counts as one "establishment" so the field is non-zero from session start
|
||||
// (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics).
|
||||
Interlocked.Increment(ref device.ReconnectCount);
|
||||
device.Client ??= _clientFactory.Create();
|
||||
try
|
||||
{
|
||||
@@ -352,6 +1103,90 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
|
||||
/// the per-device <c>Status/<field></c> fixed-tree nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
|
||||
/// </summary>
|
||||
public FocasStatusInfo? LastStatus { get; set; }
|
||||
public DateTime LastStatusUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
|
||||
/// every probe tick. Reads of the per-device <c>Production/<field></c>
|
||||
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
|
||||
/// on top of the user-driven tag reads (issue #258).
|
||||
/// </summary>
|
||||
public FocasProductionInfo? LastProduction { get; set; }
|
||||
public DateTime LastProductionUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
||||
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
|
||||
/// </summary>
|
||||
public FocasModalInfo? LastModal { get; set; }
|
||||
public DateTime LastModalUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
|
||||
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
|
||||
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
|
||||
/// </summary>
|
||||
public FocasOverrideInfo? LastOverride { get; set; }
|
||||
public DateTime LastOverrideUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
|
||||
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
|
||||
/// cache so they don't pile extra wire traffic on top of user-driven
|
||||
/// reads (issue #260).
|
||||
/// </summary>
|
||||
public FocasToolingInfo? LastTooling { get; set; }
|
||||
public DateTime LastToolingUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
|
||||
/// offsets — refreshed on every probe tick. Reads of
|
||||
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
|
||||
/// </summary>
|
||||
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
|
||||
public DateTime LastWorkOffsetsUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
|
||||
/// the four FANUC classes — refreshed on every probe tick. Reads of
|
||||
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasOperatorMessagesInfo? LastMessages { get; set; }
|
||||
public DateTime LastMessagesUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
|
||||
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
|
||||
/// serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
|
||||
public DateTime LastCurrentBlockUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
|
||||
/// Populated once per session (the increment system rarely changes mid-run);
|
||||
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
|
||||
/// surfaces position values that need scaling. Keys are axis names (or
|
||||
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int>? FigureScaling { get; set; }
|
||||
|
||||
// Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue
|
||||
// #262). Public fields rather than properties so Interlocked.Increment can
|
||||
// operate on them directly. Long-typed for the OPC UA Int64 surface.
|
||||
public long ReadCount;
|
||||
public long ReadFailureCount;
|
||||
public long ReconnectCount;
|
||||
public string? LastErrorMessage;
|
||||
public DateTime LastSuccessfulReadUtc;
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -11,17 +11,49 @@ public sealed class FocasDriverOptions
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
|
||||
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
|
||||
/// decimal-place division applied to position values before publishing.
|
||||
/// </summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
|
||||
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
|
||||
/// deployments that already published raw scaled integers can flip this to <c>false</c>
|
||||
/// for migration parity — the operator-facing concern is that switching the flag
|
||||
/// mid-deployment changes the values clients see, so the migration path is
|
||||
/// documentation-only (issue #262).
|
||||
/// </summary>
|
||||
public sealed record FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
|
||||
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
|
||||
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
|
||||
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
|
||||
/// raw integer values are published unchanged — used for migrations from
|
||||
/// older drivers that didn't apply the scaling.
|
||||
/// </summary>
|
||||
public bool ApplyFigureScaling { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
|
||||
@@ -137,6 +137,256 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||
Dummy: buf.Dummy,
|
||||
Tmmode: buf.TmMode,
|
||||
Aut: buf.Aut,
|
||||
Run: buf.Run,
|
||||
Motion: buf.Motion,
|
||||
Mstb: buf.Mstb,
|
||||
EmergencyStop: buf.Emergency,
|
||||
Alarm: buf.Alarm,
|
||||
Edit: buf.Edit));
|
||||
}
|
||||
|
||||
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
|
||||
if (!TryReadInt32Param(6711, out var produced) ||
|
||||
!TryReadInt32Param(6712, out var required) ||
|
||||
!TryReadInt32Param(6713, out var total))
|
||||
{
|
||||
return Task.FromResult<FocasProductionInfo?>(null);
|
||||
}
|
||||
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
|
||||
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
|
||||
// — the parts counters are still useful even when cycle-time isn't supported.
|
||||
var cycleSeconds = 0;
|
||||
var tmrBuf = new FwlibNative.IODBTMR();
|
||||
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
|
||||
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
|
||||
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
|
||||
PartsProduced: produced,
|
||||
PartsRequired: required,
|
||||
PartsTotal: total,
|
||||
CycleTimeSeconds: cycleSeconds));
|
||||
}
|
||||
|
||||
private bool TryReadInt32Param(ushort number, out int value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadInt16Param(ushort number, out short value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
|
||||
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
|
||||
// Best-effort — if any single read fails we still surface the others as 0; the
|
||||
// probe loop only updates the cache on a non-null return so a partial snapshot
|
||||
// is preferable to throwing away every successful field.
|
||||
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
|
||||
MCode: ReadModalAux(type: 100),
|
||||
SCode: ReadModalAux(type: 101),
|
||||
TCode: ReadModalAux(type: 102),
|
||||
BCode: ReadModalAux(type: 103)));
|
||||
}
|
||||
|
||||
private short ReadModalAux(short type)
|
||||
{
|
||||
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
|
||||
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
|
||||
if (ret != 0) return 0;
|
||||
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
|
||||
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
|
||||
// record contract; oversized values would have been truncated by FWLIB anyway.
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
}
|
||||
|
||||
public Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
|
||||
// Each parameter is independently nullable — a null parameter number keeps the
|
||||
// corresponding field at null + skips the wire call. A successful read on at
|
||||
// least one parameter is enough to publish a snapshot; this matches the
|
||||
// best-effort policy used by GetProductionAsync (issue #259).
|
||||
var feed = TryReadOverride(parameters.FeedParam);
|
||||
var rapid = TryReadOverride(parameters.RapidParam);
|
||||
var spindle = TryReadOverride(parameters.SpindleParam);
|
||||
var jog = TryReadOverride(parameters.JogParam);
|
||||
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
|
||||
}
|
||||
|
||||
private short? TryReadOverride(ushort? param)
|
||||
{
|
||||
if (param is null) return null;
|
||||
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
||||
}
|
||||
|
||||
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
var buf = new FwlibNative.IODBTNUM();
|
||||
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
|
||||
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
|
||||
var t = buf.Data;
|
||||
if (t > short.MaxValue) t = short.MaxValue;
|
||||
else if (t < short.MinValue) t = short.MinValue;
|
||||
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
|
||||
}
|
||||
|
||||
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
|
||||
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
|
||||
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
|
||||
// (the buffer ceiling) so a CNC with more axes still completes the call.
|
||||
var slots = new List<FocasWorkOffset>(6);
|
||||
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||
for (short n = 1; n <= 6; n++)
|
||||
{
|
||||
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
|
||||
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
|
||||
if (ret != 0)
|
||||
{
|
||||
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
|
||||
// still publishes so reads on the other offsets serve Good. The probe
|
||||
// loop will retry on the next tick.
|
||||
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
|
||||
continue;
|
||||
}
|
||||
slots.Add(new FocasWorkOffset(
|
||||
Name: names[n - 1],
|
||||
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
|
||||
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
|
||||
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
|
||||
}
|
||||
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
|
||||
}
|
||||
|
||||
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
|
||||
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
|
||||
// effort: a single-class failure leaves that class out of the snapshot rather
|
||||
// than failing the whole call, mirroring GetProductionAsync's policy.
|
||||
var list = new List<FocasOperatorMessage>(4);
|
||||
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
|
||||
for (short t = 0; t < 4; t++)
|
||||
{
|
||||
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
|
||||
if (ret != 0) continue;
|
||||
var text = TrimAnsiPadding(buf.Data);
|
||||
if (string.IsNullOrEmpty(text)) continue;
|
||||
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
|
||||
}
|
||||
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
|
||||
}
|
||||
|
||||
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdActPt(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
return Task.FromResult<FocasCurrentBlockInfo?>(
|
||||
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
// kind=0 → position figures (absolute/relative/machine/distance share the same
|
||||
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
|
||||
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
|
||||
// discovery in a follow-up. Issue #262, plan PR F1-f.
|
||||
short count = 0;
|
||||
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
|
||||
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
|
||||
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
|
||||
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
|
||||
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
|
||||
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
|
||||
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
|
||||
/// changing the cache contract (issue #262).
|
||||
/// </summary>
|
||||
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
|
||||
{
|
||||
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
|
||||
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < clamped; i++)
|
||||
{
|
||||
var offset = i * 8;
|
||||
if (offset + 2 > data.Length) break;
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
result[$"axis{i + 1}"] = dec;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
|
||||
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
|
||||
/// address space stays stable (issue #261). Stops at the first NUL so any wire
|
||||
/// buffer that gets reused doesn't leak old bytes.
|
||||
/// </summary>
|
||||
internal static string TrimAnsiPadding(byte[] data)
|
||||
{
|
||||
if (data is null) return string.Empty;
|
||||
var len = 0;
|
||||
for (; len < data.Length; len++)
|
||||
if (data[len] == 0) break;
|
||||
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
|
||||
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
|
||||
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
|
||||
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
|
||||
/// </summary>
|
||||
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
|
||||
{
|
||||
const int blockSize = 10;
|
||||
var offset = axisIndex * blockSize;
|
||||
if (offset + blockSize > data.Length) return 0;
|
||||
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
return raw / Math.Pow(10.0, dec);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
|
||||
@@ -88,6 +88,104 @@ internal static class FwlibNative
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Timers ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
|
||||
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
|
||||
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
|
||||
/// so the surface can grow without another P/Invoke.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||
|
||||
// ---- Modal codes ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
|
||||
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
|
||||
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
|
||||
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
|
||||
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
|
||||
/// varies by group + series (issue #259).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
||||
|
||||
// ---- Tooling ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
|
||||
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
|
||||
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
|
||||
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
|
||||
/// unions whose shape varies per series.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
|
||||
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
|
||||
|
||||
// ---- Work coordinate offsets ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
|
||||
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
|
||||
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
|
||||
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
|
||||
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
|
||||
public static extern short RdWorkOffset(
|
||||
ushort handle,
|
||||
short number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBZOFS buffer);
|
||||
|
||||
// ---- Operator messages ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
|
||||
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
|
||||
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
|
||||
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
|
||||
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdOpMsg3(
|
||||
ushort handle,
|
||||
short type,
|
||||
short length,
|
||||
ref OPMSG3 buffer);
|
||||
|
||||
// ---- Figure (per-axis decimal scaling) ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
|
||||
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
|
||||
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
|
||||
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
|
||||
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
|
||||
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
|
||||
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
|
||||
public static extern short GetFigure(
|
||||
ushort handle,
|
||||
short kind,
|
||||
ref short outCount,
|
||||
ref IODBAXIS figureinfo);
|
||||
|
||||
// ---- Currently-executing block ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
|
||||
/// reply struct holds the program / sequence numbers + the active block as a
|
||||
/// null-padded ASCII string. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +227,121 @@ internal static class FwlibNative
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
|
||||
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTMR
|
||||
{
|
||||
public int Minute;
|
||||
public int Msec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
|
||||
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
|
||||
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
|
||||
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
|
||||
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBMDL
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
|
||||
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
|
||||
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
|
||||
/// <c>Int16</c>). Issue #260, F1-d.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTNUM
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
public int Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
|
||||
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
|
||||
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
|
||||
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
|
||||
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBZOFS
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
|
||||
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
|
||||
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
|
||||
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
|
||||
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
|
||||
/// for the longest documented operator message body (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct OPMSG3
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
|
||||
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
|
||||
/// <c>char data[256]</c> (active block text). The text is null-terminated +
|
||||
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBACTPT
|
||||
{
|
||||
public int ONo;
|
||||
public int NNo;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
|
||||
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
|
||||
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
|
||||
/// of the deployment's axis count (issue #262).
|
||||
/// </summary>
|
||||
public const int MAX_AXIS = 8;
|
||||
|
||||
/// <summary>
|
||||
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
|
||||
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
|
||||
/// that axis's increment system + a unit code. The managed side reads the first
|
||||
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBAXIS
|
||||
{
|
||||
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
|
||||
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
|
||||
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
|
||||
@@ -48,8 +48,236 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||
/// </summary>
|
||||
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-CNC production counters (parts produced / required / total via
|
||||
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
|
||||
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
|
||||
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
|
||||
/// the cache untouched and the per-field nodes report Bad until the first refresh.
|
||||
/// </summary>
|
||||
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
|
||||
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
|
||||
/// issue body permits surfacing only the universally-present M/S/T/B fields in
|
||||
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasModalInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four operator override values (feed / rapid / spindle / jog) via
|
||||
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
|
||||
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
|
||||
/// field's read (the corresponding node is also omitted from the address space).
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
|
||||
/// </summary>
|
||||
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOverrideInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
|
||||
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
|
||||
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
|
||||
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
|
||||
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
|
||||
/// client cannot supply the snapshot (e.g. older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasToolingInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the standard G54..G59 work-coordinate offsets via
|
||||
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
|
||||
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
|
||||
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
|
||||
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
|
||||
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
|
||||
/// The call returns up to 4 active messages per class; the driver collapses the
|
||||
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
|
||||
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
|
||||
/// Trailing nulls / spaces are trimmed before publishing so the same message
|
||||
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot (older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
|
||||
/// The call returns the active block of the running program; surfaced as
|
||||
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
|
||||
/// wire client cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
|
||||
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
|
||||
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
|
||||
/// reports for that axis's increment system. Cached at bootstrap by the driver +
|
||||
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
|
||||
/// transport variant) — the driver leaves the cache untouched and falls back to
|
||||
/// publishing raw values.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||
/// exactly what the CNC reported.
|
||||
/// </summary>
|
||||
public sealed record FocasStatusInfo(
|
||||
short Dummy,
|
||||
short Tmmode,
|
||||
short Aut,
|
||||
short Run,
|
||||
short Motion,
|
||||
short Mstb,
|
||||
short EmergencyStop,
|
||||
short Alarm,
|
||||
short Edit);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
|
||||
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
|
||||
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
|
||||
/// <c>Int32</c> in the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasProductionInfo(
|
||||
int PartsProduced,
|
||||
int PartsRequired,
|
||||
int PartsTotal,
|
||||
int CycleTimeSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
|
||||
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
|
||||
/// the issue body permits the first cut to surface only the universally-present
|
||||
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
|
||||
/// </summary>
|
||||
public sealed record FocasModalInfo(
|
||||
short MCode,
|
||||
short SCode,
|
||||
short TCode,
|
||||
short BCode);
|
||||
|
||||
/// <summary>
|
||||
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
|
||||
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
|
||||
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
|
||||
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
|
||||
/// wire up rather than always serving Bad.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideParameters(
|
||||
ushort? FeedParam,
|
||||
ushort? RapidParam,
|
||||
ushort? SpindleParam,
|
||||
ushort? JogParam)
|
||||
{
|
||||
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
|
||||
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
|
||||
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
|
||||
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
|
||||
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideInfo(
|
||||
short? Feed,
|
||||
short? Rapid,
|
||||
short? Spindle,
|
||||
short? Jog);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently selected tool number (issue #260). Sourced from
|
||||
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
|
||||
/// interleave tool number and offset H/D codes through different FOCAS calls
|
||||
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
|
||||
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
|
||||
/// the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasToolingInfo(short CurrentTool);
|
||||
|
||||
/// <summary>
|
||||
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
|
||||
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
|
||||
/// mills typically don't expose extended rotational offsets via the standard
|
||||
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
|
||||
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
|
||||
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
|
||||
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
|
||||
/// the probe tick + served from the per-device cache by reads of the
|
||||
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
|
||||
|
||||
/// <summary>
|
||||
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
|
||||
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
|
||||
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
|
||||
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
|
||||
/// for the four message types. <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so round-trips through the OPC UA address space stay stable
|
||||
/// (issue #261).
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all active FANUC operator messages across the four message
|
||||
/// classes (issue #261). Surfaced under the FOCAS driver's
|
||||
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
|
||||
/// message in the list is what gets published. Empty list means the CNC
|
||||
/// reported no active messages; the node publishes an empty string in that
|
||||
/// case.
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently-executing program block text via
|
||||
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so the same block round-trips with stable text. Surfaced
|
||||
/// as a String node at <c>Program/CurrentBlock</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasCurrentBlockInfo(string Text);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
|
||||
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
|
||||
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counters are operational metrics, not config — they reset to zero when the
|
||||
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
|
||||
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
|
||||
/// so a quiet subscription doesn't latch the value at the last burst rate.
|
||||
/// </remarks>
|
||||
internal sealed class OpcUaClientDiagnostics
|
||||
{
|
||||
// ---- Hot-path counters (Interlocked) ----
|
||||
|
||||
private long _publishRequestCount;
|
||||
private long _notificationCount;
|
||||
private long _missingPublishRequestCount;
|
||||
private long _droppedNotificationCount;
|
||||
private long _sessionResetCount;
|
||||
|
||||
// ---- EWMA state for NotificationsPerSecond ----
|
||||
//
|
||||
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
|
||||
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
|
||||
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
|
||||
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
|
||||
// the SDK's notification path, not this lock.
|
||||
private readonly object _ewmaLock = new();
|
||||
private double _notificationsPerSecond;
|
||||
private long _lastNotificationTicks;
|
||||
|
||||
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
|
||||
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
|
||||
|
||||
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
||||
private long _lastReconnectUtcTicks;
|
||||
|
||||
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
||||
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
||||
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
||||
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
|
||||
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
|
||||
|
||||
public DateTime? LastReconnectUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
|
||||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
public double NotificationsPerSecond
|
||||
{
|
||||
get { lock (_ewmaLock) return _notificationsPerSecond; }
|
||||
}
|
||||
|
||||
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
|
||||
|
||||
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
|
||||
|
||||
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
|
||||
|
||||
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
|
||||
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
|
||||
|
||||
internal void RecordNotification(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _notificationCount);
|
||||
|
||||
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
|
||||
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
|
||||
// bursts win, idle periods bleed back to zero.
|
||||
var nowTicks = nowUtc.Ticks;
|
||||
lock (_ewmaLock)
|
||||
{
|
||||
if (_lastNotificationTicks == 0)
|
||||
{
|
||||
_lastNotificationTicks = nowTicks;
|
||||
// First sample: seed at 0 — we don't know the prior rate. The next sample
|
||||
// produces a real instRate.
|
||||
return;
|
||||
}
|
||||
var dtTicks = nowTicks - _lastNotificationTicks;
|
||||
if (dtTicks <= 0)
|
||||
{
|
||||
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
|
||||
// (count was already incremented above) so we don't divide by zero or feed
|
||||
// an absurd instRate spike.
|
||||
return;
|
||||
}
|
||||
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
|
||||
var instRate = 1.0 / dtSeconds;
|
||||
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
|
||||
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
|
||||
_lastNotificationTicks = nowTicks;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSessionReset(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _sessionResetCount);
|
||||
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
||||
/// emitted as ticks to keep the value type uniform.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, double> Snapshot()
|
||||
{
|
||||
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
||||
{
|
||||
["PublishRequestCount"] = PublishRequestCount,
|
||||
["NotificationCount"] = NotificationCount,
|
||||
["NotificationsPerSecond"] = NotificationsPerSecond,
|
||||
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
||||
["DroppedNotificationCount"] = DroppedNotificationCount,
|
||||
["SessionResetCount"] = SessionResetCount,
|
||||
};
|
||||
var last = LastReconnectUtc;
|
||||
if (last is not null)
|
||||
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
@@ -58,6 +59,23 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
private readonly OpcUaClientDriverOptions _options = options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver diagnostic counters (publish/notification rates, missing-publish,
|
||||
/// dropped-notification, session-reset). Surfaced through
|
||||
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC.
|
||||
/// Hot-path increments use <see cref="Interlocked"/>; the read path snapshots.
|
||||
/// </summary>
|
||||
private readonly OpcUaClientDiagnostics _diagnostics = new();
|
||||
|
||||
/// <summary>Test seam — exposes the live counters for unit tests.</summary>
|
||||
internal OpcUaClientDiagnostics DiagnosticsForTest => _diagnostics;
|
||||
|
||||
/// <summary>Wired to <see cref="ISession.Notification"/> in <see cref="WireSessionDiagnostics"/>; cached so we can unwire in <see cref="ShutdownAsync"/> + on reconnect.</summary>
|
||||
private NotificationEventHandler? _notificationHandler;
|
||||
|
||||
/// <summary>Wired to <see cref="ISession.PublishError"/>; cached so we can unwire on reconnect/shutdown.</summary>
|
||||
private PublishErrorEventHandler? _publishErrorHandler;
|
||||
|
||||
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
||||
internal ISession? Session { get; private set; }
|
||||
|
||||
@@ -75,6 +93,31 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
/// </summary>
|
||||
private SessionReconnectHandler? _reconnectHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Cached server-advertised OperationLimits, fetched lazily on first batch op and
|
||||
/// refreshed on reconnect. Null until the first successful fetch; null components
|
||||
/// mean "fetch hasn't completed yet, fall through to single-call". Per spec, a 0
|
||||
/// limit means "no limit" — we surface that as <c>uint?</c>=null too so the
|
||||
/// chunking helper has a single sentinel for "don't chunk".
|
||||
/// </summary>
|
||||
private OperationLimitsCache? _operationLimits;
|
||||
private readonly SemaphoreSlim _operationLimitsLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the four OperationLimits the driver chunks against. Stored as
|
||||
/// <c>uint?</c> so callers can distinguish "not yet fetched" / "no limit"
|
||||
/// (null) from "limit = N" (Some(N)). Spec sentinel 0 is normalized to null at
|
||||
/// fetch time so the chunking helper has a single "don't chunk" sentinel.
|
||||
/// </summary>
|
||||
internal sealed record OperationLimitsCache(
|
||||
uint? MaxNodesPerRead,
|
||||
uint? MaxNodesPerWrite,
|
||||
uint? MaxNodesPerBrowse,
|
||||
uint? MaxNodesPerHistoryReadData);
|
||||
|
||||
/// <summary>Test seam — exposes the cached limits so unit tests can assert fetch behaviour.</summary>
|
||||
internal OperationLimitsCache? OperationLimitsForTest => _operationLimits;
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
@@ -126,6 +169,8 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
_keepAliveHandler = OnKeepAlive;
|
||||
session.KeepAlive += _keepAliveHandler;
|
||||
|
||||
WireSessionDiagnostics(session);
|
||||
|
||||
Session = session;
|
||||
_connectedEndpointUrl = connectedUrl;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
@@ -204,17 +249,11 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
|
||||
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
|
||||
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
|
||||
// SDK versions — the validator raises an event the app has to handle.
|
||||
if (_options.AutoAcceptCertificates)
|
||||
{
|
||||
config.CertificateValidator.CertificateValidation += (s, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
||||
e.Accept = true;
|
||||
};
|
||||
}
|
||||
// Attach a cert-validator handler. The SDK's AutoAcceptUntrustedCertificates flag
|
||||
// alone isn't always enough in newer SDK versions — the validator raises an event
|
||||
// the app has to handle. We also use this hook to enforce the
|
||||
// CertificateValidation policy (revoked, SHA-1, key size) regardless of AutoAccept.
|
||||
config.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
||||
|
||||
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
||||
app.ApplicationConfiguration = config;
|
||||
@@ -224,6 +263,128 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cert-validator callback. Funnels into <see cref="EvaluateCertificateValidation"/>
|
||||
/// for testability — the static helper takes the cert + status code + options and
|
||||
/// returns the decision, which this method then applies to the SDK's event args.
|
||||
/// </summary>
|
||||
private void OnCertificateValidation(object sender, Opc.Ua.CertificateValidationEventArgs e)
|
||||
{
|
||||
var decision = EvaluateCertificateValidation(
|
||||
e.Certificate,
|
||||
e.Error.StatusCode,
|
||||
_options.AutoAcceptCertificates,
|
||||
_options.CertificateValidation);
|
||||
|
||||
if (decision.LogMessage is { Length: > 0 })
|
||||
{
|
||||
// Use the SDK's trace surface — no driver-side ILogger is plumbed today, and the
|
||||
// SDK trace is already wired up by the host. Warning level for rejections so
|
||||
// operators surface them without code changes. The non-telemetry overload is
|
||||
// marked obsolete in the latest SDK; suppress locally to keep the gateway-driver
|
||||
// surface free of an ITelemetryContext plumb-through (parity with the same
|
||||
// pattern in BuildApplicationConfigurationAsync).
|
||||
#pragma warning disable CS0618
|
||||
Opc.Ua.Utils.LogWarning(
|
||||
"OpcUaClient[{0}] cert-validation: {1} (subject={2}, status=0x{3:X8})",
|
||||
driverInstanceId, decision.LogMessage,
|
||||
e.Certificate?.Subject ?? "<null>",
|
||||
(uint)e.Error.StatusCode.Code);
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
e.Accept = decision.Accept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cert-validation decision pipeline. Pulled out as a static helper so unit tests can
|
||||
/// drive each branch without standing up an OPC UA SDK <c>CertificateValidator</c>.
|
||||
/// Order matters: revoked > SHA-1 > key-size > revocation-unknown > auto-accept-untrusted.
|
||||
/// </summary>
|
||||
/// <param name="cert">Server certificate the SDK is asking us to validate. May be null in pathological cases.</param>
|
||||
/// <param name="status">The SDK's validation result. <c>Good</c> = no failure to inspect.</param>
|
||||
/// <param name="autoAcceptUntrusted">Mirror of <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>.</param>
|
||||
/// <param name="opts">The cert-validation knobs.</param>
|
||||
internal static CertificateValidationDecision EvaluateCertificateValidation(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
|
||||
Opc.Ua.StatusCode status,
|
||||
bool autoAcceptUntrusted,
|
||||
OpcUaCertificateValidationOptions opts)
|
||||
{
|
||||
// Revoked certs are always a hard fail — never auto-accept regardless of flags.
|
||||
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevoked)
|
||||
return new CertificateValidationDecision(false, "REVOKED server certificate — rejecting");
|
||||
if (status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevoked)
|
||||
return new CertificateValidationDecision(false, "REVOKED issuer certificate — rejecting");
|
||||
|
||||
// SHA-1 signature detection runs even when the SDK didn't surface a status —
|
||||
// we want to reject SHA-1 certs on policy, not just when the SDK happens to flag them.
|
||||
if (opts.RejectSHA1SignedCertificates && IsSha1Signed(cert))
|
||||
return new CertificateValidationDecision(false, "SHA-1 signed certificate rejected by policy");
|
||||
|
||||
// Key-size check: only meaningful for RSA keys; ECC bypasses.
|
||||
if (cert is not null && TryGetRsaKeySize(cert, out var keyBits) && keyBits < opts.MinimumCertificateKeySize)
|
||||
return new CertificateValidationDecision(false,
|
||||
$"RSA key size {keyBits} bits below minimum {opts.MinimumCertificateKeySize}");
|
||||
|
||||
// Unknown revocation status — reject only if policy says so.
|
||||
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevocationUnknown
|
||||
|| status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevocationUnknown)
|
||||
{
|
||||
if (opts.RejectUnknownRevocationStatus)
|
||||
return new CertificateValidationDecision(false, "revocation status unknown (no/stale CRL) — rejecting per policy");
|
||||
return new CertificateValidationDecision(true, "revocation status unknown (no/stale CRL) — accepting per policy");
|
||||
}
|
||||
|
||||
// Untrusted: SDK couldn't chain the cert to a trusted issuer. Honour AutoAccept.
|
||||
if (status.Code == Opc.Ua.StatusCodes.BadCertificateUntrusted)
|
||||
{
|
||||
if (autoAcceptUntrusted) return new CertificateValidationDecision(true, null);
|
||||
return new CertificateValidationDecision(false, "untrusted certificate — rejecting (AutoAcceptCertificates=false)");
|
||||
}
|
||||
|
||||
// Anything else is an SDK-level failure — let the SDK's default disposition stand
|
||||
// (don't accept by default; surface the status code in the log).
|
||||
if (status.Code != Opc.Ua.StatusCodes.Good)
|
||||
return new CertificateValidationDecision(false, $"validation failed (status=0x{(uint)status.Code:X8})");
|
||||
|
||||
return new CertificateValidationDecision(true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the cert's signature algorithm OID matches a SHA-1 RSA signature
|
||||
/// (<c>1.2.840.113549.1.1.5</c>) or a SHA-1 ECDSA signature (<c>1.2.840.10045.4.1</c>).
|
||||
/// Friendly-name prefix match is unreliable across .NET runtimes, so we use OIDs.
|
||||
/// </summary>
|
||||
internal static bool IsSha1Signed(System.Security.Cryptography.X509Certificates.X509Certificate2? cert)
|
||||
{
|
||||
if (cert is null) return false;
|
||||
var oid = cert.SignatureAlgorithm?.Value;
|
||||
return oid is "1.2.840.113549.1.1.5" // sha1RSA
|
||||
or "1.2.840.10045.4.1"; // sha1ECDSA
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the RSA public key size in bits if the cert has an RSA key. Returns false for
|
||||
/// non-RSA (ECC, DSA) certs so the key-size check is skipped on them.
|
||||
/// </summary>
|
||||
internal static bool TryGetRsaKeySize(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2 cert,
|
||||
out int keyBits)
|
||||
{
|
||||
using var rsa = cert.GetRSAPublicKey();
|
||||
if (rsa is null) { keyBits = 0; return false; }
|
||||
keyBits = rsa.KeySize;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="EvaluateCertificateValidation"/>. <see cref="LogMessage"/>
|
||||
/// is null when the decision is silently "accept (Good)" — no need to log healthy
|
||||
/// validations.
|
||||
/// </summary>
|
||||
internal readonly record struct CertificateValidationDecision(bool Accept, string? LogMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||
@@ -422,17 +583,27 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
}
|
||||
_keepAliveHandler = null;
|
||||
|
||||
UnwireSessionDiagnostics(Session);
|
||||
|
||||
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
try { Session?.Dispose(); } catch { }
|
||||
Session = null;
|
||||
_connectedEndpointUrl = null;
|
||||
_operationLimits = null;
|
||||
|
||||
TransitionTo(HostState.Unknown);
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public DriverHealth GetHealth()
|
||||
{
|
||||
// Snapshot the counters into the optional Diagnostics dictionary on every poll —
|
||||
// the RPC reads through GetHealth so we can't lazy-cache without a tick source.
|
||||
// The snapshot is O(7) so the per-poll cost is negligible compared to the RPC plumbing.
|
||||
var h = _health;
|
||||
return new DriverHealth(h.State, h.LastSuccessfulRead, h.LastError, _diagnostics.Snapshot());
|
||||
}
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -442,6 +613,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -463,22 +635,33 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
if (toSend.Count == 0) return results;
|
||||
|
||||
// Honor server's MaxNodesPerRead — chunk large batches so a single ReadAsync stays
|
||||
// under the cap. cap=null means "no limit" (sentinel for both 0-from-server and
|
||||
// not-yet-fetched), in which case ChunkBy yields the input as a single slice and
|
||||
// the wire path collapses to one SDK call.
|
||||
var readCap = _operationLimits?.MaxNodesPerRead;
|
||||
var indexMapList = indexMap; // close over for catch
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var wireOffset = 0;
|
||||
foreach (var chunk in ChunkBy(toSend, readCap))
|
||||
{
|
||||
var chunkColl = new ReadValueIdCollection(chunk.Count);
|
||||
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
|
||||
var resp = await session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Both,
|
||||
nodesToRead: toSend,
|
||||
nodesToRead: chunkColl,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = resp.Results;
|
||||
for (var w = 0; w < values.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
var r = indexMapList[wireOffset + w];
|
||||
var dv = values[w];
|
||||
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
||||
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
||||
@@ -489,6 +672,8 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
||||
}
|
||||
wireOffset += chunk.Count;
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -497,9 +682,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
|
||||
// BadInternalError) so operators distinguish "upstream unreachable" from
|
||||
// "driver bug".
|
||||
for (var w = 0; w < indexMap.Count; w++)
|
||||
for (var w = 0; w < indexMapList.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
var r = indexMapList[w];
|
||||
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
@@ -515,6 +700,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
var toSend = new WriteValueCollection();
|
||||
@@ -537,25 +723,35 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
if (toSend.Count == 0) return results;
|
||||
|
||||
// Honor server's MaxNodesPerWrite — same chunking pattern as ReadAsync. cap=null
|
||||
// collapses to a single wire call.
|
||||
var writeCap = _operationLimits?.MaxNodesPerWrite;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var wireOffset = 0;
|
||||
foreach (var chunk in ChunkBy(toSend, writeCap))
|
||||
{
|
||||
var chunkColl = new WriteValueCollection(chunk.Count);
|
||||
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
|
||||
var resp = await session.WriteAsync(
|
||||
requestHeader: null,
|
||||
nodesToWrite: toSend,
|
||||
nodesToWrite: chunkColl,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var codes = resp.Results;
|
||||
for (var w = 0; w < codes.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
var r = indexMap[wireOffset + w];
|
||||
// Pass upstream WriteResult StatusCode through verbatim. Success codes
|
||||
// include Good (0) and any warning-level Good* variants; anything with
|
||||
// the severity bits set is a Bad.
|
||||
results[r] = new WriteResult(codes[w].Code);
|
||||
}
|
||||
wireOffset += chunk.Count;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -591,6 +787,81 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
private ISession RequireSession() =>
|
||||
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Lazily fetch <c>Server.ServerCapabilities.OperationLimits</c> from the upstream
|
||||
/// server and cache them on the driver. Idempotent — called from every batch op,
|
||||
/// no-ops once a successful fetch has populated the cache. The cache is cleared on
|
||||
/// reconnect (see <see cref="OnReconnectComplete"/>) so a server with redrawn
|
||||
/// capabilities doesn't run forever with stale caps.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <see cref="Session.FetchOperationLimitsAsync(CancellationToken)"/> when the
|
||||
/// active session is a concrete <see cref="Session"/> (always true in production —
|
||||
/// the SDK's session factory returns Session). Falls back gracefully on any fetch
|
||||
/// failure: callers see <see cref="_operationLimits"/> remain null and fall through
|
||||
/// to single-call behaviour. Per OPC UA Part 5, a server reporting 0 for any
|
||||
/// OperationLimits attribute means "no limit"; we normalize that to <c>null</c> so
|
||||
/// the chunking helper has a single sentinel.
|
||||
/// </remarks>
|
||||
private async Task EnsureOperationLimitsFetchedAsync(CancellationToken ct)
|
||||
{
|
||||
if (_operationLimits is not null) return;
|
||||
|
||||
await _operationLimitsLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_operationLimits is not null) return;
|
||||
if (Session is not Session concrete) return;
|
||||
|
||||
try
|
||||
{
|
||||
await concrete.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
|
||||
var ol = concrete.OperationLimits;
|
||||
if (ol is null) return;
|
||||
|
||||
_operationLimits = new OperationLimitsCache(
|
||||
MaxNodesPerRead: NormalizeLimit(ol.MaxNodesPerRead),
|
||||
MaxNodesPerWrite: NormalizeLimit(ol.MaxNodesPerWrite),
|
||||
MaxNodesPerBrowse: NormalizeLimit(ol.MaxNodesPerBrowse),
|
||||
MaxNodesPerHistoryReadData: NormalizeLimit(ol.MaxNodesPerHistoryReadData));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fetch failed — leave cache null so we re-attempt on the next batch op.
|
||||
// Single-call behaviour applies in the meantime; never block traffic on a
|
||||
// capability discovery glitch.
|
||||
}
|
||||
}
|
||||
finally { _operationLimitsLock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Spec sentinel: 0 = "no limit". Normalize to null for the chunking helper.</summary>
|
||||
private static uint? NormalizeLimit(uint raw) => raw == 0 ? null : raw;
|
||||
|
||||
/// <summary>
|
||||
/// Split <paramref name="source"/> into contiguous slices of at most <paramref name="cap"/>
|
||||
/// items. Returns the input as a single slice when the cap is null (no limit),
|
||||
/// 0, or larger than the input — the spec sentinel + the no-cap path collapse onto
|
||||
/// the same single-call branch so the wire path stays a single SDK invocation when
|
||||
/// the server doesn't impose a limit.
|
||||
/// </summary>
|
||||
internal static IEnumerable<ArraySegment<T>> ChunkBy<T>(IReadOnlyList<T> source, uint? cap)
|
||||
{
|
||||
if (source.Count == 0) yield break;
|
||||
var array = source as T[] ?? source.ToArray();
|
||||
if (cap is null or 0 || (uint)array.Length <= cap.Value)
|
||||
{
|
||||
yield return new ArraySegment<T>(array, 0, array.Length);
|
||||
yield break;
|
||||
}
|
||||
var size = checked((int)cap.Value);
|
||||
for (var offset = 0; offset < array.Length; offset += size)
|
||||
{
|
||||
var len = Math.Min(size, array.Length - offset);
|
||||
yield return new ArraySegment<T>(array, offset, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
@@ -851,29 +1122,43 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
// Route the simple-string overload through the per-tag overload with all knobs at
|
||||
// their defaults. Single code path for subscription create — keeps the wire-side
|
||||
// identical for callers that don't need per-tag tuning.
|
||||
var specs = new MonitoredTagSpec[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
specs[i] = new MonitoredTagSpec(fullReferences[i]);
|
||||
return SubscribeAsync(specs, publishingInterval, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<MonitoredTagSpec> tags, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var handle = new OpcUaSubscriptionHandle(id);
|
||||
|
||||
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
||||
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
||||
// bandwidth on every subscription create.
|
||||
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
||||
? 50
|
||||
// Floor the publishing interval — OPC UA servers routinely negotiate
|
||||
// minimum-supported intervals up anyway, but sending sub-floor values wastes
|
||||
// negotiation bandwidth on every subscription create. Floor is configurable via
|
||||
// OpcUaSubscriptionDefaults.MinPublishingIntervalMs (default 50ms).
|
||||
var subDefaults = _options.Subscriptions;
|
||||
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(subDefaults.MinPublishingIntervalMs)
|
||||
? subDefaults.MinPublishingIntervalMs
|
||||
: (int)publishingInterval.TotalMilliseconds;
|
||||
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-sub-{id}",
|
||||
PublishingInterval = intervalMs,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
KeepAliveCount = (uint)subDefaults.KeepAliveCount,
|
||||
LifetimeCount = subDefaults.LifetimeCount,
|
||||
MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
Priority = subDefaults.Priority,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
@@ -883,29 +1168,28 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var fullRef in fullReferences)
|
||||
foreach (var spec in tags)
|
||||
{
|
||||
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
|
||||
// The tag string is routed through MonitoredItem.Handle so the Notification
|
||||
// handler can identify which tag changed without an extra lookup.
|
||||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
||||
{
|
||||
DisplayName = fullRef,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
MonitoringMode = MonitoringMode.Reporting,
|
||||
SamplingInterval = intervalMs,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
})
|
||||
{
|
||||
Handle = fullRef,
|
||||
};
|
||||
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||||
subscription.AddItem(item);
|
||||
if (!TryParseNodeId(session, spec.TagName, out var nodeId)) continue;
|
||||
|
||||
var monItem = BuildMonitoredItem(spec, nodeId, intervalMs);
|
||||
monItem.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||||
subscription.AddItem(monItem);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Opc.Ua.ServiceResultException sre)
|
||||
{
|
||||
// PercentDeadband requires the server to expose EURange on the variable; if
|
||||
// it isn't set the server returns BadFilterNotAllowed during item creation.
|
||||
// We swallow the exception here so other items in the batch still get created
|
||||
// — per-item failure surfaces through MonitoredItem.Status.Error rather than
|
||||
// tearing down the whole subscription.
|
||||
if (sre.StatusCode != StatusCodes.BadFilterNotAllowed) throw;
|
||||
}
|
||||
_subscriptions[id] = new RemoteSubscription(subscription, handle);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
@@ -913,6 +1197,84 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a <see cref="MonitoredTagSpec"/> to a SDK <see cref="MonitoredItem"/> with the
|
||||
/// per-tag knobs applied. Defaults match the original hard-coded values
|
||||
/// (Reporting / SamplingInterval=publishInterval / QueueSize=1 / DiscardOldest=true)
|
||||
/// so a spec with all knobs <c>null</c> behaves identically to the legacy path.
|
||||
/// </summary>
|
||||
internal static MonitoredItem BuildMonitoredItem(MonitoredTagSpec spec, NodeId nodeId, int defaultIntervalMs)
|
||||
{
|
||||
var sampling = spec.SamplingIntervalMs.HasValue ? (int)spec.SamplingIntervalMs.Value : defaultIntervalMs;
|
||||
var queueSize = spec.QueueSize ?? 1u;
|
||||
var discardOldest = spec.DiscardOldest ?? true;
|
||||
var monitoringMode = spec.MonitoringMode is { } mm ? MapMonitoringMode(mm) : MonitoringMode.Reporting;
|
||||
var filter = BuildDataChangeFilter(spec.DataChangeFilter);
|
||||
|
||||
var options = new MonitoredItemOptions
|
||||
{
|
||||
DisplayName = spec.TagName,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
MonitoringMode = monitoringMode,
|
||||
SamplingInterval = sampling,
|
||||
QueueSize = queueSize,
|
||||
DiscardOldest = discardOldest,
|
||||
Filter = filter,
|
||||
};
|
||||
|
||||
return new MonitoredItem(telemetry: null!, options)
|
||||
{
|
||||
// The tag string is routed through MonitoredItem.Handle so the Notification
|
||||
// handler can identify which tag changed without an extra lookup.
|
||||
Handle = spec.TagName,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the OPC UA <see cref="DataChangeFilter"/> from a <see cref="DataChangeFilterSpec"/>,
|
||||
/// or return <c>null</c> if the caller didn't supply a filter. PercentDeadband requires
|
||||
/// server-side EURange — if the server rejects with BadFilterNotAllowed, the caller's
|
||||
/// <c>SubscribeAsync</c> swallows it so other items in the batch still get created.
|
||||
/// </summary>
|
||||
internal static DataChangeFilter? BuildDataChangeFilter(DataChangeFilterSpec? spec)
|
||||
{
|
||||
if (spec is null) return null;
|
||||
return new DataChangeFilter
|
||||
{
|
||||
Trigger = MapTrigger(spec.Trigger),
|
||||
DeadbandType = (uint)MapDeadbandType(spec.DeadbandType),
|
||||
DeadbandValue = spec.DeadbandValue,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map our SDK-free <see cref="SubscriptionMonitoringMode"/> to the OPC UA SDK's enum.</summary>
|
||||
internal static MonitoringMode MapMonitoringMode(SubscriptionMonitoringMode mode) => mode switch
|
||||
{
|
||||
SubscriptionMonitoringMode.Disabled => MonitoringMode.Disabled,
|
||||
SubscriptionMonitoringMode.Sampling => MonitoringMode.Sampling,
|
||||
SubscriptionMonitoringMode.Reporting => MonitoringMode.Reporting,
|
||||
_ => MonitoringMode.Reporting,
|
||||
};
|
||||
|
||||
/// <summary>Map our <see cref="Core.Abstractions.DataChangeTrigger"/> to the SDK enum.</summary>
|
||||
internal static Opc.Ua.DataChangeTrigger MapTrigger(Core.Abstractions.DataChangeTrigger trigger) => trigger switch
|
||||
{
|
||||
Core.Abstractions.DataChangeTrigger.Status => Opc.Ua.DataChangeTrigger.Status,
|
||||
Core.Abstractions.DataChangeTrigger.StatusValue => Opc.Ua.DataChangeTrigger.StatusValue,
|
||||
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp => Opc.Ua.DataChangeTrigger.StatusValueTimestamp,
|
||||
_ => Opc.Ua.DataChangeTrigger.StatusValue,
|
||||
};
|
||||
|
||||
/// <summary>Map our <see cref="Core.Abstractions.DeadbandType"/> to the SDK enum.</summary>
|
||||
internal static Opc.Ua.DeadbandType MapDeadbandType(Core.Abstractions.DeadbandType type) => type switch
|
||||
{
|
||||
Core.Abstractions.DeadbandType.None => Opc.Ua.DeadbandType.None,
|
||||
Core.Abstractions.DeadbandType.Absolute => Opc.Ua.DeadbandType.Absolute,
|
||||
Core.Abstractions.DeadbandType.Percent => Opc.Ua.DeadbandType.Percent,
|
||||
_ => Opc.Ua.DeadbandType.None,
|
||||
};
|
||||
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not OpcUaSubscriptionHandle h) return;
|
||||
@@ -975,15 +1337,16 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// match in O(1) without re-parsing on every event.
|
||||
var sourceFilter = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
|
||||
|
||||
var alarmDefaults = _options.Subscriptions;
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-alarm-sub-{id}",
|
||||
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
KeepAliveCount = (uint)alarmDefaults.KeepAliveCount,
|
||||
LifetimeCount = alarmDefaults.LifetimeCount,
|
||||
MaxNotificationsPerPublish = alarmDefaults.MaxNotificationsPerPublish,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
Priority = alarmDefaults.AlarmsPriority,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
@@ -1344,7 +1707,21 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
newSession.KeepAlive += _keepAliveHandler;
|
||||
}
|
||||
|
||||
// Move the diagnostic event hooks (Notification + PublishError) onto the new
|
||||
// session as well so counters keep flowing post-failover. Record this as a
|
||||
// session-reset for the operator dashboard.
|
||||
UnwireSessionDiagnostics(oldSession);
|
||||
if (newSession is not null)
|
||||
{
|
||||
WireSessionDiagnostics(newSession);
|
||||
_diagnostics.RecordSessionReset(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
Session = newSession;
|
||||
// Drop cached OperationLimits so the next batch op refetches against the (potentially
|
||||
// re-redeployed) upstream server. A zero-cost guard against a server whose published
|
||||
// capabilities changed across the reconnect window.
|
||||
_operationLimits = null;
|
||||
_reconnectHandler?.Dispose();
|
||||
_reconnectHandler = null;
|
||||
|
||||
@@ -1371,6 +1748,71 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the diagnostic counters onto the supplied session — every publish-response
|
||||
/// notification increments <c>NotificationCount</c> + samples the EWMA;
|
||||
/// <see cref="ISession.PublishError"/> distinguishes missing-publish vs other publish
|
||||
/// faults so operators can see whether the upstream is starving the client of publish
|
||||
/// slots vs. failing notifications outright.
|
||||
/// </summary>
|
||||
private void WireSessionDiagnostics(ISession session)
|
||||
{
|
||||
_notificationHandler = OnSessionNotification;
|
||||
_publishErrorHandler = OnSessionPublishError;
|
||||
session.Notification += _notificationHandler;
|
||||
session.PublishError += _publishErrorHandler;
|
||||
}
|
||||
|
||||
private void UnwireSessionDiagnostics(ISession? session)
|
||||
{
|
||||
if (session is null) return;
|
||||
if (_notificationHandler is not null)
|
||||
{
|
||||
try { session.Notification -= _notificationHandler; } catch { }
|
||||
}
|
||||
if (_publishErrorHandler is not null)
|
||||
{
|
||||
try { session.PublishError -= _publishErrorHandler; } catch { }
|
||||
}
|
||||
_notificationHandler = null;
|
||||
_publishErrorHandler = null;
|
||||
}
|
||||
|
||||
private void OnSessionNotification(ISession session, NotificationEventArgs e)
|
||||
{
|
||||
// Each publish response carries one NotificationMessage with N data-change /
|
||||
// event notifications. Track both cardinalities: PublishRequestCount counts
|
||||
// server publish responses delivered to us; NotificationCount counts the
|
||||
// individual MonitoredItem changes inside them. The difference matters when
|
||||
// diagnosing "many publishes, few changes" vs "few publishes, large bursts".
|
||||
_diagnostics.IncrementPublishRequest();
|
||||
var msg = e.NotificationMessage;
|
||||
if (msg?.NotificationData is { Count: > 0 } data)
|
||||
{
|
||||
for (var i = 0; i < data.Count; i++)
|
||||
{
|
||||
_diagnostics.RecordNotification();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionPublishError(ISession session, PublishErrorEventArgs e)
|
||||
{
|
||||
// BadNoSubscription / BadSequenceNumberUnknown / BadMessageNotAvailable all surface
|
||||
// as "the server expected to publish but couldn't" — bucket them as missing-publish
|
||||
// for the operator. Other status codes (timeout, network) are dropped notifications.
|
||||
var sc = e.Status?.StatusCode;
|
||||
if (sc.HasValue && IsMissingPublishStatus(sc.Value))
|
||||
_diagnostics.IncrementMissingPublishRequest();
|
||||
else
|
||||
_diagnostics.IncrementDroppedNotification();
|
||||
}
|
||||
|
||||
private static bool IsMissingPublishStatus(StatusCode sc) =>
|
||||
sc.Code == StatusCodes.BadNoSubscription
|
||||
|| sc.Code == StatusCodes.BadSequenceNumberUnknown
|
||||
|| sc.Code == StatusCodes.BadMessageNotAvailable;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -1380,5 +1822,6 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch { /* disposal is best-effort */ }
|
||||
_gate.Dispose();
|
||||
_operationLimitsLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,8 +134,106 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// browse forever.
|
||||
/// </summary>
|
||||
public int MaxBrowseDepth { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscription tuning knobs applied when the driver creates data + alarm
|
||||
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
|
||||
/// values so existing deployments see no behaviour change.
|
||||
/// </summary>
|
||||
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Server-certificate validation knobs applied during the
|
||||
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
|
||||
/// handling for revoked certs (always rejected, never auto-accepted), unknown
|
||||
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
|
||||
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
|
||||
/// existing behaviour wherever possible — the one tightening is
|
||||
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
|
||||
/// since SHA-1 is spec-deprecated for OPC UA.
|
||||
/// </summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Knobs governing the server-certificate validation callback. Plumbed onto
|
||||
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
|
||||
/// options to keep cert-related config grouped together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
|
||||
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
|
||||
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
|
||||
/// required. When the directory is absent or empty, the SDK reports
|
||||
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
|
||||
/// <see cref="RejectUnknownRevocationStatus"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RejectSHA1SignedCertificates">
|
||||
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
|
||||
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
|
||||
/// <c>false</c> only for short-term interop with legacy controllers.
|
||||
/// </param>
|
||||
/// <param name="RejectUnknownRevocationStatus">
|
||||
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
|
||||
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
|
||||
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
|
||||
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
|
||||
/// </param>
|
||||
/// <param name="MinimumCertificateKeySize">
|
||||
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
|
||||
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
|
||||
/// deployments. Non-RSA keys (ECC) bypass this check.
|
||||
/// </param>
|
||||
public sealed record OpcUaCertificateValidationOptions(
|
||||
bool RejectSHA1SignedCertificates = true,
|
||||
bool RejectUnknownRevocationStatus = false,
|
||||
int MinimumCertificateKeySize = 2048);
|
||||
|
||||
/// <summary>
|
||||
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
|
||||
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
|
||||
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
|
||||
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
|
||||
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
|
||||
/// </summary>
|
||||
/// <param name="KeepAliveCount">
|
||||
/// Number of consecutive empty publish cycles before the server sends a keep-alive
|
||||
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
|
||||
/// client notices a stalled subscription within ~5x the publish interval.
|
||||
/// </param>
|
||||
/// <param name="LifetimeCount">
|
||||
/// Number of consecutive missed publish responses before the server tears down the
|
||||
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
|
||||
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
|
||||
/// </param>
|
||||
/// <param name="MaxNotificationsPerPublish">
|
||||
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
|
||||
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
|
||||
/// </param>
|
||||
/// <param name="Priority">
|
||||
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
|
||||
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
|
||||
/// </param>
|
||||
/// <param name="MinPublishingIntervalMs">
|
||||
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
|
||||
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
|
||||
/// will only round up anyway. Default 50ms.
|
||||
/// </param>
|
||||
/// <param name="AlarmsPriority">
|
||||
/// Subscription priority for the alarm subscription (0..255). Higher than
|
||||
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
|
||||
/// data-tag bursts.
|
||||
/// </param>
|
||||
public sealed record OpcUaSubscriptionDefaults(
|
||||
int KeepAliveCount = 10,
|
||||
uint LifetimeCount = 1000,
|
||||
uint MaxNotificationsPerPublish = 0,
|
||||
byte Priority = 0,
|
||||
int MinPublishingIntervalMs = 50,
|
||||
byte AlarmsPriority = 1);
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
|
||||
@@ -26,6 +26,8 @@ public enum S7Size
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
|
||||
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,9 +50,12 @@ public readonly record struct S7ParsedAddress(
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
@@ -130,18 +135,36 @@ public static class S7AddressParser
|
||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||
|
||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
|
||||
|
||||
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
|
||||
// single-char switch below stays readable. "DBLD" is the symmetric extension of
|
||||
// DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
|
||||
{
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 4;
|
||||
}
|
||||
else if (tail.Length >= 4 && tail[2] == 'L')
|
||||
{
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
offsetStart = 3;
|
||||
size = sizeChar switch
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D/LD/L"),
|
||||
};
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
@@ -156,6 +179,15 @@ public static class S7AddressParser
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
// Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
|
||||
// the single-letter cases below.
|
||||
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
|
||||
{
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (first)
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
@@ -168,6 +200,7 @@ public static class S7AddressParser
|
||||
offsetStart = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -220,6 +221,29 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
|
||||
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
|
||||
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (bytes is null || bytes.Length != 8)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
|
||||
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
|
||||
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||
@@ -238,9 +262,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
|
||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||
|
||||
@@ -250,6 +271,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
|
||||
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
|
||||
S7Area.Memory => global::S7.Net.DataType.Memory,
|
||||
S7Area.Input => global::S7.Net.DataType.Input,
|
||||
S7Area.Output => global::S7.Net.DataType.Output,
|
||||
S7Area.Timer => global::S7.Net.DataType.Timer,
|
||||
S7Area.Counter => global::S7.Net.DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -299,6 +332,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
|
||||
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
|
||||
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var buf = new byte[8];
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Int64:
|
||||
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
|
||||
break;
|
||||
case S7DataType.UInt64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
|
||||
break;
|
||||
case S7DataType.Float64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
|
||||
break;
|
||||
}
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||
@@ -313,9 +374,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||
|
||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
@@ -351,8 +409,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Int16 => DriverDataType.Int16,
|
||||
S7DataType.UInt16 => DriverDataType.UInt16,
|
||||
S7DataType.Int32 => DriverDataType.Int32,
|
||||
S7DataType.UInt32 => DriverDataType.UInt32,
|
||||
S7DataType.Int64 => DriverDataType.Int64,
|
||||
S7DataType.UInt64 => DriverDataType.UInt64,
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayReadPlannerTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagCreateParams BaseParams(string tagName) => new(
|
||||
Gateway: "10.0.0.5",
|
||||
Port: 44818,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
TagName: tagName,
|
||||
Timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_emits_single_tag_create_with_element_count()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
|
||||
|
||||
plan.ShouldNotBeNull();
|
||||
plan.ElementType.ShouldBe(AbCipDataType.DInt);
|
||||
plan.Stride.ShouldBe(4);
|
||||
plan.Slice.Count.ShouldBe(16);
|
||||
plan.CreateParams.ElementCount.ShouldBe(16);
|
||||
// Anchored at the slice start; libplctag reads N consecutive elements from there.
|
||||
plan.CreateParams.TagName.ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_returns_null_when_path_has_no_slice()
|
||||
{
|
||||
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Bool)]
|
||||
[InlineData(AbCipDataType.String)]
|
||||
[InlineData(AbCipDataType.Structure)]
|
||||
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.SInt, 1)]
|
||||
[InlineData(AbCipDataType.Int, 2)]
|
||||
[InlineData(AbCipDataType.DInt, 4)]
|
||||
[InlineData(AbCipDataType.Real, 4)]
|
||||
[InlineData(AbCipDataType.LInt, 8)]
|
||||
[InlineData(AbCipDataType.LReal, 8)]
|
||||
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
plan.Stride.ShouldBe(expectedStride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_walks_buffer_at_element_stride()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
|
||||
fake.ValuesByOffset[0] = 100;
|
||||
fake.ValuesByOffset[4] = 200;
|
||||
fake.ValuesByOffset[8] = 300;
|
||||
fake.ValuesByOffset[12] = 400;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.Length.ShouldBe(4);
|
||||
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_slice_count_for_real_arrays()
|
||||
{
|
||||
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
fake.ValuesByOffset[0] = 1.5f;
|
||||
fake.ValuesByOffset[4] = 2.5f;
|
||||
fake.ValuesByOffset[8] = 3.5f;
|
||||
fake.ValuesByOffset[12] = 4.5f;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
|
||||
{
|
||||
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
|
||||
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
|
||||
// buffer decoded at element stride into one snapshot whose Value is an object?[].
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p =>
|
||||
{
|
||||
var t = new FakeAbCipTag(p);
|
||||
t.ValuesByOffset[0] = 10;
|
||||
t.ValuesByOffset[4] = 20;
|
||||
t.ValuesByOffset[8] = 30;
|
||||
t.ValuesByOffset[12] = 40;
|
||||
return t;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
|
||||
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
|
||||
|
||||
// Exactly ONE libplctag tag was created — anchored at the slice start with
|
||||
// ElementCount=4. Without the planner this would have been four scalar reads.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Data[0]");
|
||||
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
|
||||
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
|
||||
{
|
||||
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
|
||||
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
|
||||
// of attempting a best-effort decode.
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
|
||||
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
|
||||
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
|
||||
StringLength: 40));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
|
||||
|
||||
await drv.ReadAsync(["Banner"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
|
||||
|
||||
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
|
||||
|
||||
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_ignored_for_non_String_data_types()
|
||||
{
|
||||
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
|
||||
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
|
||||
// declared DataType.
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
|
||||
StringLength: 80));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
|
||||
{
|
||||
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
|
||||
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
|
||||
// runtime then receives the right StringMaxCapacity.
|
||||
var udt = new AbCipTagDefinition(
|
||||
Name: "Recipe",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Recipe",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
|
||||
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
|
||||
new AbCipStructureMember("Code", AbCipDataType.DInt),
|
||||
]);
|
||||
var (drv, factory) = NewDriver(udt);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
|
||||
|
||||
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
|
||||
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
|
||||
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
|
||||
{
|
||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
|
||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.4 — multi-tag write packing. Validates that <see cref="AbCipDriver.WriteAsync"/>
|
||||
/// groups writes by device, dispatches packable writes for request-packing-capable
|
||||
/// families concurrently, falls back to sequential writes on Micro800, keeps BOOL-RMW
|
||||
/// writes on the per-parent semaphore path, and fans per-tag StatusCodes out to the
|
||||
/// correct positions on partial failures.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipMultiWritePackingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Writes_get_grouped_by_device()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A1", "ab://10.0.0.5/1,0", "A1", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("A2", "ab://10.0.0.5/1,0", "A2", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B1", "ab://10.0.0.6/1,0", "B1", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A1", 1),
|
||||
new WriteRequest("B1", 100),
|
||||
new WriteRequest("A2", 2),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
// Per-device handles materialised — A1/A2 share device A, B1 lives on device B.
|
||||
factory.Tags["A1"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["A2"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["B1"].CreationParams.Gateway.ShouldBe("10.0.0.6");
|
||||
factory.Tags["A1"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["A2"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["B1"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlLogix_packs_concurrently_within_a_device()
|
||||
{
|
||||
// ControlLogix has SupportsRequestPacking=true → a multi-write batch is dispatched in
|
||||
// parallel. The fake's WriteAsync gates on a TaskCompletionSource so we can prove that
|
||||
// both writes are in flight at the same time before either completes.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var writeTask = drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
// Wait until all three writes have entered WriteAsync simultaneously, then release.
|
||||
await WaitForAsync(() => Volatile.Read(ref inFlight) >= 3, TimeSpan.FromSeconds(2));
|
||||
gate.SetResult(0);
|
||||
|
||||
var results = await writeTask;
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBeGreaterThanOrEqualTo(2,
|
||||
"ControlLogix supports request packing — packable writes should run concurrently within the device.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_falls_back_to_sequential_writes()
|
||||
{
|
||||
// Micro800 has SupportsRequestPacking=false → writes go one-at-a-time; the gated fake
|
||||
// never sees more than one in-flight at a time.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gate.SetResult(0); // No need to gate — we just observe concurrency.
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/", AbCipPlcFamily.Micro800)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results.ShouldAllBe(r => r.StatusCode == AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBe(1,
|
||||
"Micro800 disables request packing — writes must execute sequentially.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_writes_still_route_through_RMW_path()
|
||||
{
|
||||
// BOOL-with-bitIndex must hit the per-parent RMW semaphore — it must NOT go through
|
||||
// the packable per-tag runtime path. We prove this by checking that:
|
||||
// (a) the per-tag "bit-selector" runtime is never created (it would throw via
|
||||
// LibplctagTagRuntime's NotSupportedException had the bypass happened);
|
||||
// (b) the parent-DINT runtime got both a Read and a Write.
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("Flag3", true),
|
||||
new WriteRequest("Speed", 99),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created lazily for Flags (no .3 suffix) — drove the RMW.
|
||||
factory.Tags.ShouldContainKey("Flags");
|
||||
factory.Tags["Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(1);
|
||||
// Speed went through the packable path.
|
||||
factory.Tags["Speed"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_tag_status_code_fan_out_works_on_partial_failure()
|
||||
{
|
||||
// Mix Good + BadTimeout + BadNotWritable + BadNodeIdUnknown across two devices to
|
||||
// exercise the original-index preservation through the per-device plan + concurrent
|
||||
// dispatch.
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "B"
|
||||
? new FakeAbCipTag(p) { Status = -5 /* timeout */ }
|
||||
: new FakeAbCipTag(p),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("RO", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.6/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("RO", 3),
|
||||
new WriteRequest("UnknownTag", 4),
|
||||
new WriteRequest("C", 5),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(5);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
results[3].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
results[4].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!predicate())
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
throw new TimeoutException("predicate did not become true within timeout");
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake whose <see cref="WriteAsync"/> blocks on a shared
|
||||
/// <see cref="TaskCompletionSource"/> so the test can observe how many writes are
|
||||
/// simultaneously in flight inside the driver.
|
||||
/// </summary>
|
||||
private sealed class GatedWriteFake : FakeAbCipTag
|
||||
{
|
||||
private readonly TaskCompletionSource<int> _gate;
|
||||
private readonly Action _onEnter;
|
||||
private readonly Action _onExit;
|
||||
|
||||
public GatedWriteFake(AbCipTagCreateParams p, TaskCompletionSource<int> gate,
|
||||
Action onEnter, Action onExit) : base(p)
|
||||
{
|
||||
_gate = gate;
|
||||
_onEnter = onEnter;
|
||||
_onExit = onExit;
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
_onEnter();
|
||||
try
|
||||
{
|
||||
await _gate.Task.ConfigureAwait(false);
|
||||
await base.WriteAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
|
||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_basic_inclusive_range()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Data[0..15]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice.ShouldNotBeNull();
|
||||
p.Slice!.Start.ShouldBe(0);
|
||||
p.Slice.End.ShouldBe(15);
|
||||
p.Slice.Count.ShouldBe(16);
|
||||
p.BitIndex.ShouldBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Data");
|
||||
p.Segments.Single().Subscripts.ShouldBeEmpty();
|
||||
p.ToLibplctagName().ShouldBe("Data[0..15]");
|
||||
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
|
||||
// index; pair with ElementCount to cover the whole range.
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_with_program_scope_and_member_chain()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
|
||||
p.Slice!.Start.ShouldBe(3);
|
||||
p.Slice.End.ShouldBe(7);
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_zero_length_single_element_allowed()
|
||||
{
|
||||
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
|
||||
var p = AbCipTagPath.TryParse("Data[5..5]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice!.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Data[5..3]")] // M < N
|
||||
[InlineData("Data[-1..5]")] // negative start
|
||||
[InlineData("Data[0..15].Member")] // slice + sub-element
|
||||
[InlineData("Data[0..15].3")] // slice + bit index
|
||||
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
|
||||
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
|
||||
[InlineData("Data[..5]")] // missing start
|
||||
[InlineData("Data[5..]")] // missing end
|
||||
[InlineData("Data[a..5]")] // non-numeric start
|
||||
public void Invalid_slice_shapes_return_null(string input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_recomposes_round_trip()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
@@ -65,4 +66,271 @@ public sealed class AbLegacyAddressTests
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
|
||||
//
|
||||
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
|
||||
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
|
||||
// Non-I/O file letters are always decimal regardless of family.
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
|
||||
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
|
||||
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
|
||||
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
|
||||
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
|
||||
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
a.BitIndex.ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:8/0")] // word digit 8 illegal in octal
|
||||
[InlineData("I:0/9")] // bit digit 9 illegal in octal
|
||||
[InlineData("O:128/0")] // contains digit 8
|
||||
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
|
||||
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
|
||||
[InlineData("N7:8", 7, 8)]
|
||||
[InlineData("F8:9", 8, 9)]
|
||||
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileNumber.ShouldBe(expectedFile);
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
|
||||
{
|
||||
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
|
||||
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(10);
|
||||
a.BitIndex.ShouldBe(15);
|
||||
|
||||
// Decimal '8' that PLC-5 would reject is fine on SLC500.
|
||||
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
|
||||
b.ShouldNotBeNull();
|
||||
b.WordNumber.ShouldBe(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
|
||||
{
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plc5Profile_advertises_octal_io_addressing()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- MicroLogix function-file letters (Issue #245) ----
|
||||
//
|
||||
// MicroLogix 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI function files. Other
|
||||
// PCCC families (SLC500 / PLC-5 / LogixPccc) reject those file letters.
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR", "RTC", "HR")]
|
||||
[InlineData("RTC:0.MIN", "RTC", "MIN")]
|
||||
[InlineData("RTC:0.YR", "RTC", "YR")]
|
||||
[InlineData("HSC:0.ACC", "HSC", "ACC")]
|
||||
[InlineData("HSC:0.PRE", "HSC", "PRE")]
|
||||
[InlineData("HSC:0.EN", "HSC", "EN")]
|
||||
[InlineData("DLS:0.STR", "DLS", "STR")]
|
||||
[InlineData("PTO:0.OF", "PTO", "OF")]
|
||||
[InlineData("PWM:0.EN", "PWM", "EN")]
|
||||
[InlineData("STI:0.SPM", "STI", "SPM")]
|
||||
[InlineData("EII:0.PFN", "EII", "PFN")]
|
||||
[InlineData("MMI:0.FT", "MMI", "FT")]
|
||||
[InlineData("BHI:0.OS", "BHI", "OS")]
|
||||
[InlineData("IOS:0.ID", "IOS", "ID")]
|
||||
public void TryParse_MicroLogix_accepts_function_files(string input, string expectedLetter, string expectedSub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(expectedLetter);
|
||||
a.SubElement.ShouldBe(expectedSub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
[InlineData("PTO:0.OF")]
|
||||
[InlineData("BHI:0.OS")]
|
||||
public void TryParse_Slc500_rejects_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
public void TryParse_Plc5_and_LogixPccc_reject_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Default_overload_rejects_function_files()
|
||||
{
|
||||
// Without a family the parser cannot allow MicroLogix-only letters — back-compat with
|
||||
// the family-less overload from before #244.
|
||||
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("HSC:0.ACC").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicroLogixProfile_advertises_function_file_support()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsFunctionFiles.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Indirect / indexed addressing (Issue #247) ----
|
||||
//
|
||||
// PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and
|
||||
// `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the
|
||||
// inner address must itself be a plain direct PCCC reference.
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.ShouldBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectWordSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectWordSource.WordNumber.ShouldBe(0);
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBeNull();
|
||||
a.WordNumber.ShouldBe(5);
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectFileSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectFileSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.WordNumber.ShouldBe(0);
|
||||
a.IndirectWordSource.ShouldBeNull();
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_both_indirect_file_and_word()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]");
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N[N[N7:0]:0]:5")] // depth-2 file source
|
||||
[InlineData("N7:[N[N7:0]:0]")] // depth-2 word source
|
||||
[InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word)
|
||||
public void TryParse_rejects_depth_greater_than_one(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:[")] // unbalanced bracket
|
||||
[InlineData("N7:]")] // unbalanced bracket
|
||||
[InlineData("N[:5")] // empty inner file source
|
||||
[InlineData("N7:[]")] // empty inner word source
|
||||
[InlineData("N[X9:0]:5")] // unknown file letter inside
|
||||
public void TryParse_rejects_malformed_indirect(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N[N7:0]:5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_with_bit_outside_brackets()
|
||||
{
|
||||
// Outer bit applies to the resolved word; inner address is still depth-1.
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]/3");
|
||||
a.ShouldNotBeNull();
|
||||
a.BitIndex.ShouldBe(3);
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]/3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Plc5_indirect_inner_address_obeys_octal()
|
||||
{
|
||||
// Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets.
|
||||
var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8
|
||||
a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8
|
||||
|
||||
// Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5.
|
||||
AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_inner_cannot_itself_be_indirect()
|
||||
{
|
||||
AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("HSC", "ACC", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("HSC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("DLS", "STR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("DLS", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("PWM", "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
public void FunctionFile_subelement_catalogue_maps_to_expected_driver_type(
|
||||
string letter, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
|
||||
{
|
||||
AbLegacyFunctionFile.SubElementType(letter, sub).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,96 @@ public sealed class AbLegacyDriverTests
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "LEN", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "POS", DriverDataType.Int32)]
|
||||
public void EffectiveDriverDataType_resolves_subelements(
|
||||
AbLegacyDataType dataType, string subElement, DriverDataType expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDriverDataType_unknown_subelement_falls_back_to_base()
|
||||
{
|
||||
// Permissive — keeps the driver from refusing tags whose sub-element we don't catalogue.
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, "BOGUS")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, null)
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.Int, "DN")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", 14)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", 15)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", 10)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", 11)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", 12)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", 13)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", 8)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", 9)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", 10)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", 11)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", 12)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", 15)]
|
||||
public void StatusBitIndex_maps_to_standard_pccc_positions(
|
||||
AbLegacyDataType dataType, string subElement, int expectedBit)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(dataType, subElement).ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusBitIndex_for_word_subelements_is_null()
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, "PRE").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.CounterElement, "ACC").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.ControlElement, "LEN").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, null).ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.Int, "DN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", false)] // operator-controllable
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", false)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", false)]
|
||||
public void IsPlcSetStatusBit_classifies_writable_vs_status_bits(
|
||||
AbLegacyDataType dataType, string subElement, bool expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,4 +256,113 @@ public sealed class AbLegacyReadWriteTests
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Timer / Counter / Control sub-element bit semantics (issue #246) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", 13)]
|
||||
[InlineData("T4:0.TT", 14)]
|
||||
[InlineData("T4:0.EN", 15)]
|
||||
public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Seed a parent-word with only the target bit set.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
// The driver must have asked the runtime for the right bit position.
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_PRE_subelement_decodes_as_int_word()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(5000);
|
||||
factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C5:0.UN", 10)]
|
||||
[InlineData("C5:0.OV", 11)]
|
||||
[InlineData("C5:0.DN", 12)]
|
||||
[InlineData("C5:0.CD", 13)]
|
||||
[InlineData("C5:0.CU", 14)]
|
||||
public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R6:0.FD", 8)]
|
||||
[InlineData("R6:0.IN", 9)]
|
||||
[InlineData("R6:0.UL", 10)]
|
||||
[InlineData("R6:0.ER", 11)]
|
||||
[InlineData("R6:0.EM", 12)]
|
||||
[InlineData("R6:0.DN", 13)]
|
||||
[InlineData("R6:0.EU", 14)]
|
||||
[InlineData("R6:0.EN", 15)]
|
||||
public async Task Control_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_bit_returns_false_when_parent_word_bit_is_clear()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Bit 14 (TT) set, bit 13 (DN) clear.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("T4:0.TT", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("C5:0.DN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.OV", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.UN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("R6:0.ER", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.EM", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.DN", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.FD", AbLegacyDataType.ControlElement)]
|
||||
public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable(
|
||||
string address, AbLegacyDataType dataType)
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", true)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public int? LastDecodeBitIndex { get; private set; }
|
||||
public AbLegacyDataType? LastDecodeType { get; private set; }
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex)
|
||||
{
|
||||
LastDecodeType = type;
|
||||
LastDecodeBitIndex = bitIndex;
|
||||
// If the test seeded a parent-word value (ushort/short/int) and the driver asked for a
|
||||
// specific status bit, mask it out so we can assert the correct bit reaches the client.
|
||||
if (bitIndex is int bit && Value is not null and not bool)
|
||||
{
|
||||
try
|
||||
{
|
||||
var word = Convert.ToInt32(Value);
|
||||
return ((word >> bit) & 1) != 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { }
|
||||
}
|
||||
return Value;
|
||||
}
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
|
||||
// parent folders — disambiguate by FullName, which is unique per node.
|
||||
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasFigureScalingDiagnosticsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// per-axis figure scaling for the F1-f cache + diagnostics surface
|
||||
/// (issue #262).
|
||||
/// </summary>
|
||||
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
|
||||
|
||||
Task<IReadOnlyDictionary<string, int>?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Scaling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-diag", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics");
|
||||
var diagVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Diagnostics/")).ToList();
|
||||
diagVars.Count.ShouldBe(5);
|
||||
|
||||
// Verify per-field types match the documented surface (Int64 counters,
|
||||
// String error message, DateTime last-success timestamp).
|
||||
diagVars.Single(v => v.BrowseName == "ReadCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReadFailureCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReconnectCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "LastErrorMessage")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
diagVars.Single(v => v.BrowseName == "LastSuccessfulRead")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
|
||||
|
||||
foreach (var v in diagVars)
|
||||
v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks()
|
||||
{
|
||||
// Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead;
|
||||
// ReconnectCount bumps once on the initial connect (issue #262).
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Diagnostics/ReadCount",
|
||||
$"{Host}::Diagnostics/ReadFailureCount",
|
||||
$"{Host}::Diagnostics/ReconnectCount",
|
||||
$"{Host}::Diagnostics/LastErrorMessage",
|
||||
$"{Host}::Diagnostics/LastSuccessfulRead",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe
|
||||
((long)snaps[2].Value!).ShouldBe(1); // one initial connect
|
||||
snaps[3].Value.ShouldBe(string.Empty);
|
||||
((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue);
|
||||
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false()
|
||||
{
|
||||
// ProbeResult=false → success branch is skipped, ReadFailureCount bumps each
|
||||
// tick. The connect itself succeeded so ReconnectCount is 1.
|
||||
var fake = new FakeFocasClient { ProbeResult = false };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-fail", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"],
|
||||
CancellationToken.None);
|
||||
((long)snaps[0].Value!).ShouldBe(0);
|
||||
((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places()
|
||||
{
|
||||
// Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling
|
||||
// default is true → rawValue / 10^dec for the named axis (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000)
|
||||
["axis2"] = 4, // Y-axis: 4 decimal places
|
||||
},
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-fig", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for the probe-tick path to populate the cache (one successful tick is
|
||||
// enough — the figure-scaling read happens whenever the cache is null).
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// 100000 / 10^3 = 100.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0);
|
||||
// 250000 / 10^4 = 25.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0);
|
||||
// Unknown axis → raw value passes through.
|
||||
drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false()
|
||||
{
|
||||
// ApplyFigureScaling=false short-circuits before the cache lookup so the raw
|
||||
// integer is published unchanged. Migration parity for deployments that already
|
||||
// surfaced raw values from older drivers (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int> { ["axis1"] = 3 },
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false },
|
||||
}, "drv-fig-off", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false
|
||||
// means the raw value passes through unchanged.
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke so the driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer()
|
||||
{
|
||||
// Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per
|
||||
// fwlib32.h each axis entry is { short dec, short unit, short reserved,
|
||||
// short reserved2 } = 8 bytes; we only read dec.
|
||||
var buf = new byte[FwlibNative.MAX_AXIS * 8];
|
||||
// Axis 1: dec=3
|
||||
buf[0] = 3; buf[1] = 0;
|
||||
// Axis 2: dec=4
|
||||
buf[8] = 4; buf[9] = 0;
|
||||
// Axis 3: dec=0 (already zero)
|
||||
|
||||
var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3);
|
||||
map.Count.ShouldBe(3);
|
||||
map["axis1"].ShouldBe(3);
|
||||
map["axis2"].ShouldBe(4);
|
||||
map["axis3"].ShouldBe(0);
|
||||
|
||||
// Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't
|
||||
// overrun the buffer.
|
||||
var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99);
|
||||
clamped.Count.ShouldBe(FwlibNative.MAX_AXIS);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMessagesBlockTextFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
|
||||
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
|
||||
/// fixed-tree (issue #261).
|
||||
/// </summary>
|
||||
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasOperatorMessagesInfo? Messages { get; set; }
|
||||
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
|
||||
|
||||
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Messages);
|
||||
|
||||
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
|
||||
Task.FromResult(CurrentBlock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
|
||||
|
||||
var latest = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Messages/External/Latest");
|
||||
latest.BrowseName.ShouldBe("Latest");
|
||||
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
|
||||
var block = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Program/CurrentBlock");
|
||||
block.BrowseName.ShouldBe("CurrentBlock");
|
||||
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
|
||||
{
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo(
|
||||
[
|
||||
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
|
||||
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
|
||||
]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Messages/External/Latest",
|
||||
$"{Host}::Program/CurrentBlock",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
|
||||
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
|
||||
snaps[0].Value.ShouldBe("DOOR OPEN");
|
||||
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither cache populates; the nodes still resolve as known
|
||||
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
|
||||
// policy.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg-empty", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
|
||||
{
|
||||
// Empty snapshot (CNC reported no active messages) still publishes Good +
|
||||
// empty string — operators distinguish "no messages" from "Bad" without
|
||||
// having to read separate availability nodes.
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo([]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo(""),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-empty-snap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].Value.ShouldBe(string.Empty);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snaps[1].Value.ShouldBe(string.Empty);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke. Returns null → driver leaves the cache
|
||||
// untouched, matching the policy in f1a/f1b/f1c/f1d.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
|
||||
{
|
||||
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
|
||||
// managed side trims them so the same message round-trips with stable text
|
||||
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
|
||||
var buf = new byte[16];
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
|
||||
Array.Copy(bytes, buf, bytes.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
|
||||
|
||||
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
|
||||
// at the NUL so leftover bytes in the rest of the buffer are ignored.
|
||||
var buf2 = new byte[32];
|
||||
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
|
||||
Array.Copy(bytes2, buf2, bytes2.Length);
|
||||
// After NUL the buffer has zeros — already invisible — but explicit space
|
||||
// padding before the NUL should be trimmed.
|
||||
var buf3 = new byte[32];
|
||||
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
|
||||
Array.Copy(bytes3, buf3, bytes3.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
|
||||
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
|
||||
|
||||
// Empty buffer → empty string (no exception).
|
||||
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasModalOverrideFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.6:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
|
||||
/// </summary>
|
||||
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasModalInfo? Modal { get; set; }
|
||||
public FocasOverrideInfo? Override { get; set; }
|
||||
public FocasOverrideParameters? LastOverrideParams { get; private set; }
|
||||
|
||||
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Modal);
|
||||
|
||||
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken ct)
|
||||
{
|
||||
LastOverrideParams = parameters;
|
||||
return Task.FromResult(Override);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-modal", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
|
||||
var modalVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Modal/")).ToList();
|
||||
modalVars.Count.ShouldBe(4);
|
||||
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-no-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_only_configured_Override_fields()
|
||||
{
|
||||
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-partial-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
|
||||
var overrideVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Override/")).ToList();
|
||||
overrideVars.Count.ShouldBe(2);
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ModalAwareFakeFocasClient
|
||||
{
|
||||
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
|
||||
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-modal-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Modal/MCode",
|
||||
$"{Host}::Modal/SCode",
|
||||
$"{Host}::Modal/TCode",
|
||||
$"{Host}::Modal/BCode",
|
||||
$"{Host}::Override/Feed",
|
||||
$"{Host}::Override/Rapid",
|
||||
$"{Host}::Override/Spindle",
|
||||
$"{Host}::Override/Jog",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)8);
|
||||
snaps[1].Value.ShouldBe((short)1200);
|
||||
snaps[2].Value.ShouldBe((short)101);
|
||||
snaps[3].Value.ShouldBe((short)0);
|
||||
snaps[4].Value.ShouldBe((short)100);
|
||||
snaps[5].Value.ShouldBe((short)50);
|
||||
snaps[6].Value.ShouldBe((short)110);
|
||||
snaps[7].Value.ShouldBe((short)25);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// The driver hands the device's configured override parameters to the wire client
|
||||
// verbatim — defaulting to 30i numbers.
|
||||
fake.LastOverrideParams.ShouldNotBeNull();
|
||||
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
|
||||
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither modal nor override caches populate; the nodes still
|
||||
// resolve as known references but report Bad until the first successful poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-cache", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must short-
|
||||
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetOverrideAsync(
|
||||
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasProductionFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
|
||||
/// </summary>
|
||||
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasProductionInfo? Production { get; set; }
|
||||
|
||||
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Production);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_nodes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
|
||||
var prodVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Production/")).ToList();
|
||||
prodVars.Count.ShouldBe(4);
|
||||
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ProductionAwareFakeFocasClient
|
||||
{
|
||||
Production = new FocasProductionInfo(
|
||||
PartsProduced: 17,
|
||||
PartsRequired: 100,
|
||||
PartsTotal: 4242,
|
||||
CycleTimeSeconds: 73),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate the cache.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Production/PartsProduced"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Production/PartsProduced",
|
||||
$"{Host}::Production/PartsRequired",
|
||||
$"{Host}::Production/PartsTotal",
|
||||
$"{Host}::Production/CycleTimeSeconds",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe(17);
|
||||
snaps[1].Value.ShouldBe(100);
|
||||
snaps[2].Value.ShouldBe(4242);
|
||||
snaps[3].Value.ShouldBe(73);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
|
||||
{
|
||||
// Probe disabled — cache never populates; the production nodes still resolve as
|
||||
// known references but report Bad until the first successful poll lands.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Production/PartsProduced"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); calling GetProductionAsync on the
|
||||
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||
// in its current state.
|
||||
var client = new FwlibFocasClient();
|
||||
var result = await client.GetProductionAsync(CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasStatusFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
|
||||
/// keeps its existing boolean semantic so the back-compat path stays exercised.
|
||||
/// </summary>
|
||||
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasStatusInfo? Status { get; set; }
|
||||
|
||||
// Shadow the default interface implementation with a real one. Explicit interface
|
||||
// form so callers via IFocasClient hit this override; FakeFocasClient itself
|
||||
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
|
||||
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
|
||||
var statusVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Status/")).ToList();
|
||||
statusVars.Count.ShouldBe(9);
|
||||
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = statusVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Status/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
|
||||
{
|
||||
var fake = new StatusAwareFakeFocasClient
|
||||
{
|
||||
Status = new FocasStatusInfo(
|
||||
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
|
||||
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate the cache.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Status/Tmmode",
|
||||
$"{Host}::Status/Aut",
|
||||
$"{Host}::Status/Run",
|
||||
$"{Host}::Status/Motion",
|
||||
$"{Host}::Status/Mstb",
|
||||
$"{Host}::Status/EmergencyStop",
|
||||
$"{Host}::Status/Alarm",
|
||||
$"{Host}::Status/Edit",
|
||||
$"{Host}::Status/Dummy",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)1); // Tmmode
|
||||
snaps[1].Value.ShouldBe((short)2); // Aut
|
||||
snaps[2].Value.ShouldBe((short)3); // Run
|
||||
snaps[3].Value.ShouldBe((short)4); // Motion
|
||||
snaps[4].Value.ShouldBe((short)5); // Mstb
|
||||
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
|
||||
snaps[6].Value.ShouldBe((short)7); // Alarm
|
||||
snaps[7].Value.ShouldBe((short)6); // Edit
|
||||
snaps[8].Value.ShouldBe((short)0); // Dummy
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
|
||||
{
|
||||
// Probe disabled — cache never populates; the status nodes still resolve as
|
||||
// known references but report Bad until the first successful poll lands.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
|
||||
{
|
||||
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
|
||||
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
|
||||
// but the probe still flips host state to Running.
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(() => Task.FromResult(
|
||||
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
|
||||
// but the rest of the driver keeps functioning.
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
|
||||
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||
// in its current state.
|
||||
var client = new FwlibFocasClient();
|
||||
var result = await client.GetStatusAsync(CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasToolingOffsetsFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
|
||||
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
|
||||
/// </summary>
|
||||
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasToolingInfo? Tooling { get; set; }
|
||||
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
|
||||
|
||||
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Tooling);
|
||||
|
||||
Task<FocasWorkOffsetsInfo?> IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) =>
|
||||
Task.FromResult(WorkOffsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-tooling", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling");
|
||||
var toolingVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Tooling/")).ToList();
|
||||
toolingVars.Count.ShouldBe(1);
|
||||
var node = toolingVars.Single();
|
||||
node.BrowseName.ShouldBe("CurrentTool");
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes()
|
||||
{
|
||||
// Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per
|
||||
// device. Extended G54.1 P1..P48 deferred per the F1-d plan.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-offsets", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Offsets");
|
||||
string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||
foreach (var slot in expectedSlots)
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == slot);
|
||||
var offsetVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Offsets/")).ToList();
|
||||
offsetVars.Count.ShouldBe(6 * 3);
|
||||
foreach (var slot in expectedSlots)
|
||||
foreach (var axis in new[] { "X", "Y", "Z" })
|
||||
{
|
||||
var fullRef = $"{Host}::Offsets/{slot}/{axis}";
|
||||
var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ToolingAwareFakeFocasClient
|
||||
{
|
||||
Tooling = new FocasToolingInfo(CurrentTool: 17),
|
||||
WorkOffsets = new FocasWorkOffsetsInfo(
|
||||
[
|
||||
new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0),
|
||||
new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3),
|
||||
]),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-tooling-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Tooling/CurrentTool"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Tooling/CurrentTool",
|
||||
$"{Host}::Offsets/G54/X",
|
||||
$"{Host}::Offsets/G54/Y",
|
||||
$"{Host}::Offsets/G54/Z",
|
||||
$"{Host}::Offsets/G59/X",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)17);
|
||||
snaps[1].Value.ShouldBe(100.5);
|
||||
snaps[2].Value.ShouldBe(200.25);
|
||||
snaps[3].Value.ShouldBe(-50.0);
|
||||
snaps[4].Value.ShouldBe(1.0);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither tooling nor offsets caches populate; the nodes
|
||||
// still resolve as known references but report Bad until the first poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-tooling", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke. Returns null → driver leaves the cache
|
||||
// untouched, matching the policy in f1a/f1b/f1c.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode()
|
||||
{
|
||||
// Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes.
|
||||
// Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0).
|
||||
var buf = new byte[80];
|
||||
WriteAxis(buf, 0, raw: 12345, dec: 3);
|
||||
WriteAxis(buf, 1, raw: -500, dec: 2);
|
||||
WriteAxis(buf, 2, raw: 0, dec: 0);
|
||||
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9);
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9);
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9);
|
||||
}
|
||||
|
||||
private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec)
|
||||
{
|
||||
var offset = axisIndex * 10;
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the cert-validation knobs added in PR #277. Live revocation testing
|
||||
/// requires standing up a CA + CRL; we cover the parts that are testable without one:
|
||||
/// option defaults, the static decision pipeline, SHA-1 detection, and key-size checks.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_match_documented_policy()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.CertificateValidation.RejectSHA1SignedCertificates.ShouldBeTrue(
|
||||
"SHA-1 is spec-deprecated for OPC UA — default must be hard-fail.");
|
||||
opts.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse(
|
||||
"Default must allow brownfield deployments without CRL infrastructure.");
|
||||
opts.CertificateValidation.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoked_cert_is_rejected_even_when_AutoAccept_is_true()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevoked),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("REVOKED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Issuer_revoked_is_rejected_even_when_AutoAccept_is_true()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateIssuerRevoked),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("REVOKED issuer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevocationUnknown_default_accepts_with_log_note()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = false });
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
decision.LogMessage!.ShouldContain("revocation status unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevocationUnknown_with_strict_flag_rejects()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true });
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("revocation status unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha1_signed_cert_is_rejected_by_default()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("SHA-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha1_acceptance_can_be_opted_back_into()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
|
||||
// Untrusted + auto-accept = let it through; SHA-1 must NOT be the failing reason.
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateUntrusted),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions { RejectSHA1SignedCertificates = false });
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Small_rsa_key_is_rejected_below_minimum()
|
||||
{
|
||||
using var cert = CreateRsaCert(1024, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("1024");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetRsaKeySize_reports_correct_bit_count()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
OpcUaClientDriver.TryGetRsaKeySize(cert, out var bits).ShouldBeTrue();
|
||||
bits.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSha1Signed_detects_sha1_signature()
|
||||
{
|
||||
using var sha1Cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
using var sha256Cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
OpcUaClientDriver.IsSha1Signed(sha1Cert).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsSha1Signed(sha256Cert).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsSha1Signed(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrusted_without_AutoAccept_is_rejected()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateUntrusted),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("untrusted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Good_status_with_compliant_cert_accepts_silently()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
decision.LogMessage.ShouldBeNull("Good validations shouldn't emit log noise.");
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateRsaCert(int keySize, HashAlgorithmName hash)
|
||||
{
|
||||
// .NET 10's CertificateRequest.CreateSelfSigned rejects SHA-1 outright. For the
|
||||
// SHA-256 path we use the supported API; for SHA-1 we route through a custom
|
||||
// X509SignatureGenerator that signs with SHA-1 OID so we can synthesise a SHA-1
|
||||
// signed cert in-process without shipping a binary fixture.
|
||||
var rsa = RSA.Create(keySize);
|
||||
var req = new CertificateRequest(
|
||||
new System.Security.Cryptography.X509Certificates.X500DistinguishedName(
|
||||
"CN=OpcUaClientCertValidationTests"),
|
||||
rsa,
|
||||
hash == HashAlgorithmName.SHA1 ? HashAlgorithmName.SHA256 : hash,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
if (hash == HashAlgorithmName.SHA1)
|
||||
{
|
||||
var generator = new Sha1RsaSignatureGenerator(rsa);
|
||||
var serial = new byte[8];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(serial);
|
||||
var built = req.Create(
|
||||
req.SubjectName,
|
||||
generator,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow.AddHours(1),
|
||||
serial);
|
||||
// Combine cert + key so GetRSAPublicKey works downstream.
|
||||
return built.CopyWithPrivateKey(rsa);
|
||||
}
|
||||
|
||||
return req.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA-1 RSA signature generator. .NET 10's <see cref="X509SignatureGenerator.CreateForRSA"/>
|
||||
/// refuses SHA-1; we subclass to emit the SHA-1 RSA algorithm identifier
|
||||
/// (<c>1.2.840.113549.1.1.5</c>) and sign with SHA-1 explicitly. Test-only.
|
||||
/// </summary>
|
||||
private sealed class Sha1RsaSignatureGenerator : X509SignatureGenerator
|
||||
{
|
||||
private readonly RSA _rsa;
|
||||
public Sha1RsaSignatureGenerator(RSA rsa) { _rsa = rsa; }
|
||||
|
||||
public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithm)
|
||||
{
|
||||
// DER: SEQUENCE { OID 1.2.840.113549.1.1.5, NULL }
|
||||
return new byte[]
|
||||
{
|
||||
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05, 0x05, 0x00,
|
||||
};
|
||||
}
|
||||
|
||||
public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithm)
|
||||
=> _rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1);
|
||||
|
||||
protected override PublicKey BuildPublicKey() => PublicKey.CreateFromSubjectPublicKeyInfo(
|
||||
_rsa.ExportSubjectPublicKeyInfo(), out _);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the per-driver diagnostic counters surfaced via
|
||||
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC
|
||||
/// (task #276). Counters are exercised directly through the internal helper rather
|
||||
/// than via a live SDK <c>ISession</c> because the SDK requires a connected upstream
|
||||
/// to publish events and we want unit-level coverage of the math + the snapshot shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Counters_default_to_zero()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.PublishRequestCount.ShouldBe(0);
|
||||
d.NotificationCount.ShouldBe(0);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
d.MissingPublishRequestCount.ShouldBe(0);
|
||||
d.DroppedNotificationCount.ShouldBe(0);
|
||||
d.SessionResetCount.ShouldBe(0);
|
||||
d.LastReconnectUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.PublishRequestCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementMissingPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.MissingPublishRequestCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementDroppedNotification_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementDroppedNotification();
|
||||
d.DroppedNotificationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordNotification_grows_count_and_then_rate()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
// First sample seeds the EWMA — rate stays 0 until we have a delta.
|
||||
d.RecordNotification(t0);
|
||||
d.NotificationCount.ShouldBe(1);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
|
||||
// 1 Hz steady state: 30 samples spaced 1s apart converge toward 1/s. With 5s half-life
|
||||
// and alpha=0.5^(1/5)≈0.871, the EWMA approaches 1 - alpha^N — after 30 samples that's
|
||||
// 1 - 0.871^30 ≈ 0.984.
|
||||
for (var i = 1; i <= 30; i++)
|
||||
d.RecordNotification(t0.AddSeconds(i));
|
||||
|
||||
d.NotificationCount.ShouldBe(31);
|
||||
d.NotificationsPerSecond.ShouldBeInRange(0.95, 1.05, "EWMA at 5s half-life converges to ~1Hz after 30 samples");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSessionReset_bumps_count_and_sets_last_reconnect()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t = new DateTime(2026, 4, 25, 12, 34, 56, DateTimeKind.Utc);
|
||||
d.RecordSessionReset(t);
|
||||
d.SessionResetCount.ShouldBe(1);
|
||||
d.LastReconnectUtc.ShouldBe(t);
|
||||
|
||||
// Second reset overwrites timestamp + bumps count.
|
||||
var t2 = t.AddMinutes(5);
|
||||
d.RecordSessionReset(t2);
|
||||
d.SessionResetCount.ShouldBe(2);
|
||||
d.LastReconnectUtc.ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_emits_well_known_keys()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.RecordNotification(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementDroppedNotification();
|
||||
d.RecordSessionReset(new DateTime(2026, 4, 25, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var snap = d.Snapshot();
|
||||
|
||||
snap.ShouldContainKey("PublishRequestCount");
|
||||
snap["PublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationCount");
|
||||
snap["NotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationsPerSecond");
|
||||
snap.ShouldContainKey("MissingPublishRequestCount");
|
||||
snap["MissingPublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("DroppedNotificationCount");
|
||||
snap["DroppedNotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("SessionResetCount");
|
||||
snap["SessionResetCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_omits_LastReconnectUtcTicks_when_no_reset_recorded()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.Snapshot().ShouldNotContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_GetHealth_includes_diagnostics_dictionary()
|
||||
{
|
||||
// GetHealth must expose the snapshot to the RPC consumer even before any session
|
||||
// has been opened — operators call it during startup to check counters baseline.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test");
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics.ShouldNotBeNull();
|
||||
health.Diagnostics!.ShouldContainKey("PublishRequestCount");
|
||||
health.Diagnostics["PublishRequestCount"].ShouldBe(0);
|
||||
health.Diagnostics.ShouldContainKey("NotificationCount");
|
||||
health.Diagnostics.ShouldContainKey("SessionResetCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_health_diagnostics_reflect_internal_counters_after_increment()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test-2");
|
||||
|
||||
// Drive a counter through the test seam to prove the GetHealth snapshot is live,
|
||||
// not a one-shot at construction.
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics!["PublishRequestCount"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverHealth_default_diagnostics_is_null_but_DiagnosticsOrEmpty_is_empty()
|
||||
{
|
||||
// Back-compat: pre-existing call sites that construct DriverHealth with the
|
||||
// 3-arg overload must keep working — the 4th param defaults to null.
|
||||
var h = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
h.Diagnostics.ShouldBeNull();
|
||||
h.DiagnosticsOrEmpty.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,45 @@ public sealed class OpcUaClientDriverScaffoldTests
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_subscription_tuning_matches_prior_hard_coded_values()
|
||||
{
|
||||
// PR #273: lifted hard-coded Subscription parameters into options; defaults MUST
|
||||
// remain wire-identical so existing deployments see no behaviour change.
|
||||
var subs = new OpcUaClientDriverOptions().Subscriptions;
|
||||
subs.KeepAliveCount.ShouldBe(10);
|
||||
subs.LifetimeCount.ShouldBe(1000u);
|
||||
subs.MaxNotificationsPerPublish.ShouldBe(0u, "0 = unlimited per OPC UA spec");
|
||||
subs.Priority.ShouldBe((byte)0);
|
||||
subs.MinPublishingIntervalMs.ShouldBe(50);
|
||||
subs.AlarmsPriority.ShouldBe((byte)1, "alarms get a higher priority than data tags so they aren't starved during bursts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscription_defaults_are_overridable_via_options()
|
||||
{
|
||||
// Operators tuning a flaky-network deployment should be able to bump LifetimeCount /
|
||||
// lower MaxNotificationsPerPublish without recompiling the driver. Verify the record
|
||||
// is overridable end-to-end.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
Subscriptions = new OpcUaSubscriptionDefaults(
|
||||
KeepAliveCount: 25,
|
||||
LifetimeCount: 5000u,
|
||||
MaxNotificationsPerPublish: 200u,
|
||||
Priority: 7,
|
||||
MinPublishingIntervalMs: 100,
|
||||
AlarmsPriority: 9),
|
||||
};
|
||||
|
||||
opts.Subscriptions.KeepAliveCount.ShouldBe(25);
|
||||
opts.Subscriptions.LifetimeCount.ShouldBe(5000u);
|
||||
opts.Subscriptions.MaxNotificationsPerPublish.ShouldBe(200u);
|
||||
opts.Subscriptions.Priority.ShouldBe((byte)7);
|
||||
opts.Subscriptions.MinPublishingIntervalMs.ShouldBe(100);
|
||||
opts.Subscriptions.AlarmsPriority.ShouldBe((byte)9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MonitoredTagSpec"/> -> SDK <c>MonitoredItem</c> mapping.
|
||||
/// Assertion-only — no live SDK session required, so the tests run on every CI without
|
||||
/// a real OPC UA server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientMonitoredTagSpecTests
|
||||
{
|
||||
private static readonly NodeId SampleNodeId = new("Demo", 2);
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_all_defaults_matches_legacy_hard_coded_values()
|
||||
{
|
||||
// Spec with every per-tag knob null should behave identically to the legacy
|
||||
// string-only SubscribeAsync path: Reporting / SamplingInterval=publishInterval /
|
||||
// QueueSize=1 / DiscardOldest=true / no filter.
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Demo");
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
|
||||
item.SamplingInterval.ShouldBe(250);
|
||||
item.QueueSize.ShouldBe(1u);
|
||||
item.DiscardOldest.ShouldBeTrue();
|
||||
item.MonitoringMode.ShouldBe(MonitoringMode.Reporting);
|
||||
item.Filter.ShouldBeNull();
|
||||
item.Handle.ShouldBe("ns=2;s=Demo",
|
||||
"the tag string is routed through Handle so the Notification callback can identify the changed tag without re-parsing DisplayName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_applies_per_tag_sampling_interval_independent_of_publish_interval()
|
||||
{
|
||||
// Per-tag SamplingInterval lets the server sample faster than it publishes — useful
|
||||
// for events that change between publish ticks. If the spec sets it explicitly, the
|
||||
// mapping uses that value, not the publish-interval default.
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Fast", SamplingIntervalMs: 50);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 1000);
|
||||
item.SamplingInterval.ShouldBe(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_applies_queue_size_and_discard_oldest_overrides()
|
||||
{
|
||||
var spec = new MonitoredTagSpec("ns=2;s=DeepQueue", QueueSize: 100, DiscardOldest: false);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
item.QueueSize.ShouldBe(100u);
|
||||
item.DiscardOldest.ShouldBeFalse(
|
||||
"discard-oldest=false preserves earliest values — useful for audit-trail subscriptions where the first overflow sample is the most diagnostic");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubscriptionMonitoringMode.Disabled, MonitoringMode.Disabled)]
|
||||
[InlineData(SubscriptionMonitoringMode.Sampling, MonitoringMode.Sampling)]
|
||||
[InlineData(SubscriptionMonitoringMode.Reporting, MonitoringMode.Reporting)]
|
||||
public void BuildMonitoredItem_maps_each_monitoring_mode(SubscriptionMonitoringMode input, MonitoringMode expected)
|
||||
{
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Mode", MonitoringMode: input);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
item.MonitoringMode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_absolute_deadband_emits_DataChangeFilter()
|
||||
{
|
||||
var spec = new MonitoredTagSpec(
|
||||
"ns=2;s=Analog",
|
||||
DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValue,
|
||||
Core.Abstractions.DeadbandType.Absolute,
|
||||
DeadbandValue: 0.5));
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
|
||||
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValue);
|
||||
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Absolute);
|
||||
filter.DeadbandValue.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_percent_deadband_emits_percent_filter()
|
||||
{
|
||||
// PercentDeadband is calculated server-side as a fraction of EURange; the driver
|
||||
// emits the filter unconditionally and lets the server return BadFilterNotAllowed
|
||||
// if EURange isn't set on the variable. SubscribeAsync's catch-block swallows that
|
||||
// status so other items in the batch still get created.
|
||||
var spec = new MonitoredTagSpec(
|
||||
"ns=2;s=Pct",
|
||||
DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp,
|
||||
Core.Abstractions.DeadbandType.Percent,
|
||||
DeadbandValue: 5.0));
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
|
||||
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValueTimestamp);
|
||||
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Percent);
|
||||
filter.DeadbandValue.ShouldBe(5.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.Status, Opc.Ua.DataChangeTrigger.Status)]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValue, Opc.Ua.DataChangeTrigger.StatusValue)]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValueTimestamp, Opc.Ua.DataChangeTrigger.StatusValueTimestamp)]
|
||||
public void MapTrigger_round_trips_each_enum_value(
|
||||
Core.Abstractions.DataChangeTrigger input, Opc.Ua.DataChangeTrigger expected)
|
||||
=> OpcUaClientDriver.MapTrigger(input).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Core.Abstractions.DeadbandType.None, Opc.Ua.DeadbandType.None)]
|
||||
[InlineData(Core.Abstractions.DeadbandType.Absolute, Opc.Ua.DeadbandType.Absolute)]
|
||||
[InlineData(Core.Abstractions.DeadbandType.Percent, Opc.Ua.DeadbandType.Percent)]
|
||||
public void MapDeadbandType_round_trips_each_enum_value(
|
||||
Core.Abstractions.DeadbandType input, Opc.Ua.DeadbandType expected)
|
||||
=> OpcUaClientDriver.MapDeadbandType(input).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultInterfaceImplementation_routes_through_legacy_overload()
|
||||
{
|
||||
// ISubscribable's default interface impl of the per-tag overload delegates to the
|
||||
// simple-string overload, ignoring per-tag knobs. Drivers that DON'T override the
|
||||
// new overload (Modbus / S7 / Galaxy / TwinCAT / FOCAS / AbCip / AbLegacy) still
|
||||
// accept MonitoredTagSpec lists and just pass through the tag names — back-compat
|
||||
// for ISubscribable consumers.
|
||||
var stub = new StubSubscribableDriver();
|
||||
var specs = new[]
|
||||
{
|
||||
new MonitoredTagSpec("Tag1", SamplingIntervalMs: 50, QueueSize: 5),
|
||||
new MonitoredTagSpec("Tag2", DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValue,
|
||||
Core.Abstractions.DeadbandType.Absolute,
|
||||
1.0)),
|
||||
};
|
||||
|
||||
ISubscribable iface = stub;
|
||||
_ = await iface.SubscribeAsync(specs, TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
|
||||
|
||||
stub.LastTagNames.ShouldBe(["Tag1", "Tag2"]);
|
||||
stub.LastPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-double <see cref="ISubscribable"/> that records whatever the legacy
|
||||
/// <c>SubscribeAsync(IReadOnlyList<string>, ...)</c> overload was called with.
|
||||
/// Used to verify the default-impl per-tag overload routes correctly without needing
|
||||
/// a real OPC UA session.
|
||||
/// </summary>
|
||||
private sealed class StubSubscribableDriver : ISubscribable
|
||||
{
|
||||
public IReadOnlyList<string>? LastTagNames { get; private set; }
|
||||
public TimeSpan LastPublishingInterval { get; private set; }
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
LastTagNames = fullReferences;
|
||||
LastPublishingInterval = publishingInterval;
|
||||
return Task.FromResult<ISubscriptionHandle>(new StubHandle());
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
#pragma warning disable CS0067 // event never used — the test only asserts the SubscribeAsync call routing
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
#pragma warning restore CS0067
|
||||
}
|
||||
|
||||
private sealed record StubHandle() : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "stub";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the OperationLimits chunking surface (PR #275 / opcuaclient-3). Focused
|
||||
/// on the static <see cref="OpcUaClientDriver.ChunkBy{T}"/> helper + the
|
||||
/// <see cref="OpcUaClientDriver.OperationLimitsCache"/> sentinel semantics. Live
|
||||
/// end-to-end tests against an in-process server land in the integration suite.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientOperationLimitsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_5_splits_12_items_into_3_slices_of_5_5_2()
|
||||
{
|
||||
// The PR-3 acceptance scenario: server advertises MaxNodesPerRead=5, client batches a
|
||||
// 12-tag read; driver must issue exactly 3 wire calls of sizes 5/5/2 in order.
|
||||
var input = Enumerable.Range(0, 12).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(3);
|
||||
slices[0].Count.ShouldBe(5);
|
||||
slices[1].Count.ShouldBe(5);
|
||||
slices[2].Count.ShouldBe(2);
|
||||
// Order + offsets must reflect the original sequence — chunking must not reorder
|
||||
// tags, otherwise the indexMap ↔ result-index alignment breaks.
|
||||
slices[0].ShouldBe(new[] { 0, 1, 2, 3, 4 });
|
||||
slices[1].ShouldBe(new[] { 5, 6, 7, 8, 9 });
|
||||
slices[2].ShouldBe(new[] { 10, 11 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_null_cap_yields_single_slice_no_chunking()
|
||||
{
|
||||
// cap=null is the "fetch hasn't completed" / "server reports 0 = no limit" sentinel.
|
||||
// Both must collapse to a single SDK call so the wire path doesn't change when the
|
||||
// server doesn't impose a cap.
|
||||
var input = Enumerable.Range(0, 12).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: null).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1, "null cap means no chunking — single SDK call");
|
||||
slices[0].Count.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_zero_cap_yields_single_slice_no_chunking()
|
||||
{
|
||||
// OPC UA Part 5: 0 is the wire-level "no limit" sentinel. NormalizeLimit folds it
|
||||
// into null upstream of ChunkBy, but the chunker itself must also treat 0 as
|
||||
// no-chunking — defence in depth in case a caller bypasses NormalizeLimit.
|
||||
var input = Enumerable.Range(0, 7).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 0).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_larger_than_input_yields_single_slice()
|
||||
{
|
||||
var input = new[] { 1, 2, 3 };
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 100).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_empty_input_yields_no_slices()
|
||||
{
|
||||
// Empty batch must short-circuit before the wire call — saves a round-trip and
|
||||
// matches the !toSend.Count == 0 guard in the driver.
|
||||
var input = Array.Empty<int>();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_equal_to_input_size_yields_single_slice()
|
||||
{
|
||||
// Edge case: exactly N items at cap N. Must NOT produce an extra empty slice.
|
||||
var input = Enumerable.Range(0, 5).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_1_splits_each_item_into_its_own_slice()
|
||||
{
|
||||
// Pathological cap — degrades to N wire calls. Verifies the chunker handles the
|
||||
// boundary cleanly without off-by-one.
|
||||
var input = new[] { 10, 20, 30 };
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 1).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(3);
|
||||
slices[0].ShouldBe(new[] { 10 });
|
||||
slices[1].ShouldBe(new[] { 20 });
|
||||
slices[2].ShouldBe(new[] { 30 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationLimitsCache_records_all_four_caps_as_nullable_uint()
|
||||
{
|
||||
// The cache surfaces the four limits the driver chunks against. Storing as uint?
|
||||
// lets the chunker distinguish "not yet fetched" / "no limit" (null) from "limit=N".
|
||||
var cache = new OpcUaClientDriver.OperationLimitsCache(
|
||||
MaxNodesPerRead: 100u,
|
||||
MaxNodesPerWrite: 50u,
|
||||
MaxNodesPerBrowse: null,
|
||||
MaxNodesPerHistoryReadData: 10u);
|
||||
|
||||
cache.MaxNodesPerRead.ShouldBe(100u);
|
||||
cache.MaxNodesPerWrite.ShouldBe(50u);
|
||||
cache.MaxNodesPerBrowse.ShouldBeNull();
|
||||
cache.MaxNodesPerHistoryReadData.ShouldBe(10u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_starts_with_no_cached_OperationLimits()
|
||||
{
|
||||
// Pre-init / pre-first-batch state: cache is null so callers fall through to
|
||||
// single-call behaviour. Lazy fetch happens on the first ReadAsync/WriteAsync.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cache-init");
|
||||
|
||||
drv.OperationLimitsForTest.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public sealed class S7AddressParserTests
|
||||
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
|
||||
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
|
||||
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
|
||||
[InlineData("DB1.DBLD0", 1, S7Size.LWord, 0, 0)] // 64-bit long DWord
|
||||
[InlineData("DB1.DBL8", 1, S7Size.LWord, 8, 0)] // 64-bit alt suffix (LReal)
|
||||
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
|
||||
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
|
||||
@@ -53,6 +55,9 @@ public sealed class S7AddressParserTests
|
||||
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
|
||||
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
|
||||
[InlineData("MLD0", S7Area.Memory, S7Size.LWord, 0, 0)] // 64-bit Merker
|
||||
[InlineData("ILD8", S7Area.Input, S7Size.LWord, 8, 0)]
|
||||
[InlineData("QLD16", S7Area.Output, S7Size.LWord, 16, 0)]
|
||||
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
|
||||
@@ -65,6 +65,34 @@ public sealed class S7DiscoveryAndSubscribeTests
|
||||
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_maps_64bit_types_to_matching_DriverDataType()
|
||||
{
|
||||
// PR-S7-A1: 64-bit scalar types must surface with their native DriverDataType
|
||||
// (not collapse to Int32) so the OPC UA address-space layer publishes the right
|
||||
// BuiltInType. Address suffixes: DBLD (DB long-DWord), MLD/ILD/QLD (M/I/Q long-DWord).
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("BigInt", "DB1.DBLD0", S7DataType.Int64),
|
||||
new("BigUInt", "DB1.DBLD8", S7DataType.UInt64),
|
||||
new("BigDouble", "DB1.DBLD16", S7DataType.Float64),
|
||||
new("MerkerLong", "MLD0", S7DataType.Int64),
|
||||
],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-64bit");
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Variables.Single(v => v.Name == "BigInt").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
builder.Variables.Single(v => v.Name == "BigUInt").Attr.DriverDataType.ShouldBe(DriverDataType.UInt64);
|
||||
builder.Variables.Single(v => v.Name == "BigDouble").Attr.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
builder.Variables.Single(v => v.Name == "MerkerLong").Attr.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user