Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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.Bool => DriverDataType.Boolean,
|
||||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => 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.Real => DriverDataType.Float32,
|
||||||
AbCipDataType.LReal => DriverDataType.Float64,
|
AbCipDataType.LReal => DriverDataType.Float64,
|
||||||
AbCipDataType.String => DriverDataType.String,
|
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
|
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||||
DataType: member.DataType,
|
DataType: member.DataType,
|
||||||
Writable: member.Writable,
|
Writable: member.Writable,
|
||||||
WriteIdempotent: member.WriteIdempotent);
|
WriteIdempotent: member.WriteIdempotent,
|
||||||
|
StringLength: member.StringLength);
|
||||||
_tagsByName[memberTag.Name] = memberTag;
|
_tagsByName[memberTag.Name] = memberTag;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||||
@@ -372,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
var bitIndex = parsedPath?.BitIndex;
|
||||||
var bitIndex = tagPath?.BitIndex;
|
|
||||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_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>
|
/// <summary>
|
||||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
/// 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
|
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||||
@@ -451,100 +545,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// ---- IWritable ----
|
// ---- IWritable ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
/// plan decisions #44, #45, #143 the caller opts in via
|
||||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||||||
|
/// errors as <c>BadCommunicationError</c>.
|
||||||
/// </summary>
|
/// </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(
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(writes);
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
var results = new WriteResult[writes.Count];
|
var results = new WriteResult[writes.Count];
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
for (var i = 0; i < writes.Count; i++)
|
var plans = AbCipMultiWritePlanner.Build(
|
||||||
|
writes, _tagsByName, _devices,
|
||||||
|
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
||||||
|
|
||||||
|
foreach (var plan in plans)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
continue;
|
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
}
|
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// Bit-RMW writes always serialise per-parent — never packed.
|
||||||
{
|
foreach (var entry in plan.BitRmw)
|
||||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
results[entry.OriginalIndex] = new WriteResult(
|
||||||
|
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
if (plan.Packable.Count == 0) continue;
|
||||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
|
||||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||||||
// losing one another's update.
|
{
|
||||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
// 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++)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(
|
var entry = plan.Packable[i];
|
||||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||||||
.ConfigureAwait(false));
|
|
||||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
foreach (var (idx, code) in outcomes)
|
||||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
results[idx] = new WriteResult(code);
|
||||||
await runtime.WriteAsync(cancellationToken).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);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
else
|
||||||
{
|
{
|
||||||
throw;
|
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||||||
}
|
foreach (var entry in plan.Packable)
|
||||||
catch (NotSupportedException nse)
|
{
|
||||||
{
|
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
.ConfigureAwait(false);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||||||
}
|
}
|
||||||
catch (FormatException fe)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
|
||||||
}
|
|
||||||
catch (InvalidCastException ice)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
|
||||||
}
|
|
||||||
catch (OverflowException oe)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
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;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
if (status == 0)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||||
|
}
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
|
catch (FormatException fe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (InvalidCastException ice)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (OverflowException oe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||||
@@ -633,7 +811,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
CipPath: device.ParsedAddress.CipPath,
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
TagName: parsed.ToLibplctagName(),
|
TagName: parsed.ToLibplctagName(),
|
||||||
Timeout: _options.Timeout));
|
Timeout: _options.Timeout,
|
||||||
|
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
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
|
/// 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
|
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||||
/// write attempt failing at runtime.</param>
|
/// 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(
|
public sealed record AbCipTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition(
|
|||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||||
bool SafetyTag = false);
|
bool SafetyTag = false,
|
||||||
|
int? StringLength = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
/// 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,
|
string Name,
|
||||||
AbCipDataType DataType,
|
AbCipDataType DataType,
|
||||||
bool Writable = true,
|
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>
|
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||||
public enum AbCipPlcFamily
|
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(
|
public sealed record AbCipTagPath(
|
||||||
string? ProgramScope,
|
string? ProgramScope,
|
||||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
AbCipTagPathSlice? Slice = null)
|
||||||
{
|
{
|
||||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||||
public string ToLibplctagName()
|
public string ToLibplctagName()
|
||||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
|||||||
if (seg.Subscripts.Count > 0)
|
if (seg.Subscripts.Count > 0)
|
||||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
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);
|
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||||
return buf.ToString();
|
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>
|
/// <summary>
|
||||||
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
/// 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
|
/// 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);
|
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('[');
|
var bracketIdx = part.IndexOf('[');
|
||||||
if (bracketIdx < 0)
|
if (bracketIdx < 0)
|
||||||
{
|
{
|
||||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
|||||||
var name = part[..bracketIdx];
|
var name = part[..bracketIdx];
|
||||||
if (!IsValidIdent(name)) return null;
|
if (!IsValidIdent(name)) return null;
|
||||||
var inner = part[(bracketIdx + 1)..^1];
|
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>();
|
var subs = new List<int>();
|
||||||
foreach (var tok in inner.Split(','))
|
foreach (var tok in inner.Split(','))
|
||||||
{
|
{
|
||||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
|||||||
}
|
}
|
||||||
if (segments.Count == 0) return null;
|
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)
|
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>
|
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
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="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="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="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(
|
public sealed record AbCipTagCreateParams(
|
||||||
string Gateway,
|
string Gateway,
|
||||||
int Port,
|
int Port,
|
||||||
string CipPath,
|
string CipPath,
|
||||||
string LibplctagPlcAttribute,
|
string LibplctagPlcAttribute,
|
||||||
string TagName,
|
string TagName,
|
||||||
TimeSpan Timeout);
|
TimeSpan Timeout,
|
||||||
|
int? StringMaxCapacity = null,
|
||||||
|
int? ElementCount = null);
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
Name = p.TagName,
|
Name = p.TagName,
|
||||||
Timeout = p.Timeout,
|
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);
|
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||||
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||||
AbCipDataType.String => _tag.GetString(offset),
|
AbCipDataType.String => _tag.GetString(offset),
|
||||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||||
AbCipDataType.Structure => null,
|
AbCipDataType.Structure => null,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Dt:
|
case AbCipDataType.Dt:
|
||||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Structure:
|
case AbCipDataType.Structure:
|
||||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
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;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
|
|||||||
int? FileNumber,
|
int? FileNumber,
|
||||||
int WordNumber,
|
int WordNumber,
|
||||||
int? BitIndex,
|
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()
|
public string ToLibplctagName()
|
||||||
{
|
{
|
||||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||||
var wordPart = $"{file}:{WordNumber}";
|
// 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 (SubElement is not null) wordPart += $".{SubElement}";
|
||||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||||
return wordPart;
|
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;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var src = value.Trim();
|
var src = value.Trim();
|
||||||
|
|
||||||
// BitIndex: trailing /N
|
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||||
int? bitIndex = null;
|
|
||||||
var slashIdx = src.IndexOf('/');
|
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||||
if (slashIdx >= 0)
|
// 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;
|
bitText = src[(slashIdx + 1)..];
|
||||||
bitIndex = bit;
|
|
||||||
src = src[..slashIdx];
|
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.)
|
// 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;
|
string? subElement = null;
|
||||||
var dotIdx = src.LastIndexOf('.');
|
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||||
if (dotIdx >= 0)
|
if (dotIdx >= 0)
|
||||||
{
|
{
|
||||||
var candidate = src[(dotIdx + 1)..];
|
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;
|
if (colonIdx <= 0) return null;
|
||||||
var filePart = src[..colonIdx];
|
var filePart = src[..colonIdx];
|
||||||
var wordPart = src[(colonIdx + 1)..];
|
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;
|
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||||
var letterEnd = 1;
|
var letterEnd = 1;
|
||||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||||
|
|
||||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||||
int? fileNumber = null;
|
int? fileNumber = null;
|
||||||
|
AbLegacyAddress? indirectFile = null;
|
||||||
if (letterEnd < filePart.Length)
|
if (letterEnd < filePart.Length)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
var fileTail = filePart[letterEnd..];
|
||||||
fileNumber = fn;
|
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.
|
// 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
|
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,
|
"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,
|
_ => 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,
|
CounterElement,
|
||||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||||
ControlElement,
|
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>
|
/// <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.String => DriverDataType.String,
|
||||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||||
|
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
|
||||||
_ => 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
// 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);
|
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
}
|
}
|
||||||
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
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
|
// 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
|
// 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));
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var tag in tagsForDevice)
|
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(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: effectiveType,
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
ArrayDim: null,
|
ArrayDim: null,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: tag.Writable && !plcSetBit
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
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;
|
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(
|
?? throw new InvalidOperationException(
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
$"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(
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
Port: device.ParsedAddress.Port,
|
Port: device.ParsedAddress.Port,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
/// 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>
|
/// </summary>
|
||||||
public sealed record AbLegacyTagDefinition(
|
public sealed record AbLegacyTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||||
AbLegacyDataType.String => _tag.GetString(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
|
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,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
string DefaultCipPath,
|
string DefaultCipPath,
|
||||||
int MaxTagBytes,
|
int MaxTagBytes,
|
||||||
bool SupportsStringFile,
|
bool SupportsStringFile,
|
||||||
bool SupportsLongFile)
|
bool SupportsLongFile,
|
||||||
|
bool OctalIoAddressing,
|
||||||
|
bool SupportsFunctionFiles)
|
||||||
{
|
{
|
||||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||||
{
|
{
|
||||||
@@ -25,21 +27,27 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
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(
|
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||||
LibplctagPlcAttribute: "micrologix",
|
LibplctagPlcAttribute: "micrologix",
|
||||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||||
MaxTagBytes: 232,
|
MaxTagBytes: 232,
|
||||||
SupportsStringFile: true,
|
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(
|
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||||
LibplctagPlcAttribute: "plc5",
|
LibplctagPlcAttribute: "plc5",
|
||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||||
SupportsStringFile: true,
|
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>
|
/// <summary>
|
||||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||||
@@ -51,7 +59,9 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240,
|
MaxTagBytes: 240,
|
||||||
SupportsStringFile: true,
|
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>
|
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => int.MaxValue,
|
_ => 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)
|
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||||
{
|
{
|
||||||
var (min, max) = MacroRange(series);
|
var (min, max) = MacroRange(series);
|
||||||
|
|||||||
@@ -24,8 +24,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = 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);
|
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<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
@@ -76,6 +164,67 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_tagsByName[tag.Name] = tag;
|
_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)
|
if (_options.Probe.Enabled)
|
||||||
{
|
{
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
@@ -113,6 +262,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.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);
|
_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++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
var reference = fullReferences[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))
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
@@ -257,10 +482,267 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: tag.WriteIdempotent));
|
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;
|
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) ----
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||||
|
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
@@ -283,13 +765,123 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var success = false;
|
var success = false;
|
||||||
|
string? failureMessage = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||||
success = await client.ProbeAsync(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 (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);
|
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)
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
{
|
{
|
||||||
HostState old;
|
HostState old;
|
||||||
@@ -324,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (device.Client is { IsConnected: true } c) return c;
|
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();
|
device.Client ??= _clientFactory.Create();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -352,6 +1103,90 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||||
public CancellationTokenSource? ProbeCts { get; set; }
|
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()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -11,17 +11,49 @@ public sealed class FocasDriverOptions
|
|||||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||||
public FocasProbeOptions Probe { get; init; } = new();
|
public FocasProbeOptions Probe { get; init; } = new();
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
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>
|
/// <summary>
|
||||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
/// <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>
|
/// </summary>
|
||||||
public sealed record FocasDeviceOptions(
|
public sealed record FocasDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||||
|
FocasOverrideParameters? OverrideParameters = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// 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);
|
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 ----
|
// ---- PMC ----
|
||||||
|
|
||||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
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)]
|
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
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 ----
|
// ---- Structs ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -129,6 +227,121 @@ internal static class FwlibNative
|
|||||||
public short DecVal; // decimal-point count
|
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>
|
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
public struct ODBST
|
public struct ODBST
|
||||||
|
|||||||
@@ -48,8 +48,236 @@ public interface IFocasClient : IDisposable
|
|||||||
/// responds with any valid status.
|
/// responds with any valid status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
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>
|
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||||
public interface IFocasClientFactory
|
public interface IFocasClientFactory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
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]
|
[Fact]
|
||||||
public async Task Cancellation_propagates_from_read()
|
public async Task Cancellation_propagates_from_read()
|
||||||
{
|
{
|
||||||
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
|
|||||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
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.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
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.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
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");
|
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]
|
[Fact]
|
||||||
public void ToLibplctagName_recomposes_round_trip()
|
public void ToLibplctagName_recomposes_round_trip()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
@@ -65,4 +66,271 @@ public sealed class AbLegacyAddressTests
|
|||||||
a.ShouldNotBeNull();
|
a.ShouldNotBeNull();
|
||||||
a.ToLibplctagName().ShouldBe(input);
|
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.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
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;
|
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 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 EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||||
public virtual void Dispose() => Disposed = true;
|
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");
|
||||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
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);
|
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
|
||||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
// 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 ----
|
// ---- 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) { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user