Compare commits

...

34 Commits

Author SHA1 Message Date
dohertj2 a04ba2af7a Merge pull request '[opcuaclient] OpcUaClient — Honor server OperationLimits' (#333) from auto/opcuaclient/3 into auto/driver-gaps 2026-04-25 15:41:17 -04:00
Joseph Doherty 494fdf2358 Auto: opcuaclient-3 — honor server OperationLimits
Closes #275
2026-04-25 15:38:55 -04:00
dohertj2 9f1e033e83 Merge pull request '[opcuaclient] OpcUaClient — Per-tag advanced subscription tuning incl. deadband' (#332) from auto/opcuaclient/2 into auto/driver-gaps 2026-04-25 15:27:51 -04:00
Joseph Doherty fae00749ca Auto: opcuaclient-2 — per-tag advanced subscription tuning
Closes #274
2026-04-25 15:25:20 -04:00
dohertj2 bf200e813e Merge pull request '[opcuaclient] OpcUaClient — Per-subscription tuning' (#331) from auto/opcuaclient/1 into auto/driver-gaps 2026-04-25 15:11:23 -04:00
Joseph Doherty 7209364c35 Auto: opcuaclient-1 — per-subscription tuning
Closes #273
2026-04-25 15:09:08 -04:00
dohertj2 8314c273e7 Merge pull request '[focas] FOCAS — Figure scaling + diagnostics' (#330) from auto/focas/F1-f into auto/driver-gaps 2026-04-25 15:04:05 -04:00
Joseph Doherty 1abf743a9f Auto: focas-f1f — figure scaling + diagnostics
Closes #262
2026-04-25 15:01:37 -04:00
dohertj2 63a79791cd Merge pull request '[focas] FOCAS — Operator messages + block text' (#329) from auto/focas/F1-e into auto/driver-gaps 2026-04-25 14:51:46 -04:00
Joseph Doherty cc757855e6 Auto: focas-f1e — operator messages + block text
Closes #261
2026-04-25 14:49:11 -04:00
dohertj2 84913638b1 Merge pull request '[focas] FOCAS — Tool number + work coordinate offsets' (#328) from auto/focas/F1-d into auto/driver-gaps 2026-04-25 14:40:17 -04:00
Joseph Doherty 9ec92a9082 Auto: focas-f1d — Tool number + work coordinate offsets
Closes #260
2026-04-25 14:37:51 -04:00
dohertj2 49fc23adc6 Merge pull request '[focas] FOCAS — Modal codes + overrides' (#327) from auto/focas/F1-c into auto/driver-gaps 2026-04-25 14:29:12 -04:00
Joseph Doherty 3c2c4f29ea Auto: focas-f1c — Modal codes + overrides
Closes #259

Adds Modal/ + Override/ fixed-tree subfolders per FOCAS device, mirroring the
pattern established by Status/ (#257) and Production/ (#258): cached snapshots
refreshed on the probe tick, served from cache on read, no extra wire traffic
on top of user-driven tag reads.

Modal/ surfaces the four universally-present aux modal codes M/S/T/B from
cnc_modal(type=100..103) as Int16. **G-group decoding (groups 1..21) is deferred
to a follow-up** — the FWLIB ODBMDL union differs per series + group and the
issue body explicitly permits this scoping. Adds the cnc_modal P/Invoke +
ODBMDL struct + a generic int16 cnc_rdparam helper so the follow-up can add
G-groups without further wire-level scaffolding.

Override/ surfaces Feed/Rapid/Spindle/Jog from cnc_rdparam at MTB-specific
parameter numbers (FocasDeviceOptions.OverrideParameters; defaults to 30i:
6010/6011/6014/6015). Per-field nullable params let a deployment hide overrides
their MTB doesn't wire up; passing OverrideParameters=null suppresses the entire
Override/ subfolder for that device.

6 unit tests cover discovery shape, omitted Override folder when unconfigured,
partial Override field selection, cached-snapshot reads (Modal + Override),
BadCommunicationError before first refresh, and the FwlibFocasClient
disconnected short-circuit.
2026-04-25 14:26:48 -04:00
dohertj2 ae7cc15178 Merge pull request '[focas] FOCAS — Parts count + cycle time' (#326) from auto/focas/F1-b into auto/driver-gaps 2026-04-25 14:17:17 -04:00
Joseph Doherty 3d9697b918 Auto: focas-f1b — parts count + cycle time
Closes #258
2026-04-25 14:14:54 -04:00
dohertj2 329e222aa2 Merge pull request '[focas] FOCAS — ODBST status flags as fixed-tree nodes' (#325) from auto/focas/F1-a into auto/driver-gaps 2026-04-25 14:07:42 -04:00
Joseph Doherty 551494d223 Auto: focas-f1a — ODBST status flags as fixed-tree nodes
Closes #257
2026-04-25 14:05:12 -04:00
dohertj2 5b4925e61a Merge pull request '[ablegacy] AbLegacy — Indirect/indexed addressing parser' (#324) from auto/ablegacy/4 into auto/driver-gaps 2026-04-25 13:53:28 -04:00
Joseph Doherty 4ff4cc5899 Auto: ablegacy-4 — indirect/indexed addressing parser
Closes #247
2026-04-25 13:51:03 -04:00
dohertj2 b95eaacc05 Merge pull request '[ablegacy] AbLegacy — Sub-element bit semantics' (#323) from auto/ablegacy/3 into auto/driver-gaps 2026-04-25 13:44:21 -04:00
Joseph Doherty c89f5bb3b9 Auto: ablegacy-3 — sub-element bit semantics
Closes #246
2026-04-25 13:41:52 -04:00
dohertj2 07235d3b66 Merge pull request '[ablegacy] AbLegacy — MicroLogix function-file letters' (#322) from auto/ablegacy/2 into auto/driver-gaps 2026-04-25 13:34:46 -04:00
Joseph Doherty f2bc36349e Auto: ablegacy-2 — MicroLogix function-file letters
Closes #245
2026-04-25 13:32:23 -04:00
dohertj2 ccf2e3a9c0 Merge pull request '[ablegacy] AbLegacy — PLC-5 octal I/O addressing' (#321) from auto/ablegacy/1 into auto/driver-gaps 2026-04-25 13:26:54 -04:00
Joseph Doherty 8f7265186d Auto: ablegacy-1 — PLC-5 octal I/O addressing
Closes #244
2026-04-25 13:25:22 -04:00
dohertj2 651d6c005c Merge pull request '[abcip] AbCip — CIP multi-tag write packing' (#320) from auto/abcip/1.4 into auto/driver-gaps 2026-04-25 13:16:49 -04:00
Joseph Doherty 36b2929780 Auto: abcip-1.4 — CIP multi-tag write packing
Group writes by device through new AbCipMultiWritePlanner; for families that
support CIP request packing (ControlLogix / CompactLogix / GuardLogix) the
packable writes for one device are dispatched concurrently so libplctag's
native scheduler can coalesce them onto one Multi-Service Packet (0x0A).
Micro800 keeps SupportsRequestPacking=false and falls back to per-tag
sequential writes. BOOL-within-DINT writes are excluded from packing and
continue to go through the per-parent RMW semaphore so two concurrent bit
writes against the same DINT cannot lose one another's update.

The libplctag .NET wrapper does not expose a Multi-Service Packet construction
API at the per-Tag surface (each Tag is one CIP service), so this PR uses
client-side coalescing — concurrent Task.WhenAll dispatch per device — rather
than building raw CIP frames. The native libplctag scheduler does pack
concurrent same-connection writes when the family allows it, which gives the
round-trip reduction #228 calls for without ballooning the diff.

Per-tag StatusCodes preserve caller order across success, transport failure,
non-writable tags, unknown references, and unknown devices, including in
mixed concurrent batches.

Closes #228
2026-04-25 13:14:28 -04:00
dohertj2 345ac97c43 Merge pull request '[abcip] AbCip — Array-slice read addressing Tag[0..N]' (#319) from auto/abcip/1.3 into auto/driver-gaps 2026-04-25 13:06:11 -04:00
Joseph Doherty 767ac4aec5 Auto: abcip-1.3 — array-slice read addressing
Closes #227
2026-04-25 13:03:45 -04:00
dohertj2 29edd835a3 Merge pull request '[abcip] AbCip — STRINGnn variant decoding' (#318) from auto/abcip/1.2 into auto/driver-gaps 2026-04-25 12:55:34 -04:00
Joseph Doherty d78a471e90 Auto: abcip-1.2 — STRINGnn variant decoding
Closes #226

Adds nullable StringLength to AbCipTagDefinition + AbCipStructureMember
so STRING_20 / STRING_40 / STRING_80 UDT variants decode against the
right DATA-array capacity. The configured length threads through a new
StringMaxCapacity field on AbCipTagCreateParams and lands on the
libplctag Tag.StringMaxCapacity attribute (verified property on
libplctag 1.5.2). Null leaves libplctag's default 82-byte STRING in
place for back-compat. Driver gates on DataType == String so a stray
StringLength on a DINT tag doesn't reshape that buffer. UDT member
fan-out copies StringLength from the AbCipStructureMember onto the
synthesised member tag definition.

Tests: 4 new in AbCipDriverReadTests covering threaded StringMaxCapacity,
the null back-compat path, the non-String gate, and the UDT-member fan-out.
2026-04-25 12:53:20 -04:00
dohertj2 1d9e40236b Merge pull request '[abcip] AbCip — LINT/ULINT 64-bit fidelity' (#317) from auto/abcip/1.1 into auto/driver-gaps 2026-04-25 12:47:17 -04:00
Joseph Doherty 2e6228a243 Auto: abcip-1.1 — LINT/ULINT 64-bit fidelity
Closes #225
2026-04-25 12:44:43 -04:00
43 changed files with 5745 additions and 192 deletions
@@ -25,7 +25,7 @@ public enum DriverCapability
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
Discover,
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
/// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
Subscribe,
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
@@ -20,7 +20,29 @@ public interface ISubscribable
TimeSpan publishingInterval,
CancellationToken cancellationToken);
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
/// <summary>
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
/// delegate to the simple
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
/// overload — the default implementation does exactly that, so existing implementers
/// compile unchanged.
/// </summary>
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<MonitoredTagSpec> tags,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
=> SubscribeAsync(
tags.Select(t => t.TagName).ToList(),
publishingInterval,
cancellationToken);
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>
@@ -30,7 +52,7 @@ public interface ISubscribable
event EventHandler<DataChangeEventArgs>? OnDataChange;
}
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
public interface ISubscriptionHandle
{
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
}
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle,
string FullReference,
DataValueSnapshot Snapshot);
/// <summary>
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
/// </summary>
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
/// <param name="SamplingIntervalMs">
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
/// coalesce), useful for events that change between publish ticks.
/// </param>
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
/// <param name="DiscardOldest">
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
/// <c>null</c> = driver default (true — preserve recency).
/// </param>
/// <param name="MonitoringMode">
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
/// </param>
/// <param name="DataChangeFilter">
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
/// (every change publishes regardless of magnitude).
/// </param>
public sealed record MonitoredTagSpec(
string TagName,
double? SamplingIntervalMs = null,
uint? QueueSize = null,
bool? DiscardOldest = null,
SubscriptionMonitoringMode? MonitoringMode = null,
DataChangeFilterSpec? DataChangeFilter = null);
/// <summary>
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
/// as metadata even if they ignore the deadband mechanics.
/// </summary>
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
/// <param name="DeadbandValue">
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
/// </param>
public sealed record DataChangeFilterSpec(
DataChangeTrigger Trigger,
DeadbandType DeadbandType,
double DeadbandValue);
/// <summary>
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
/// leak an OPC-UA-stack reference into every driver project.
/// </summary>
public enum DataChangeTrigger
{
/// <summary>Fire only when StatusCode changes.</summary>
Status = 0,
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
StatusValue = 1,
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
StatusValueTimestamp = 2,
}
/// <summary>OPC UA deadband-filter modes.</summary>
public enum DeadbandType
{
/// <summary>No deadband — every value change publishes.</summary>
None = 0,
/// <summary>Deadband expressed in the variable's engineering units.</summary>
Absolute = 1,
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
Percent = 2,
}
/// <summary>
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
/// so Core.Abstractions stays SDK-free.
/// </summary>
public enum SubscriptionMonitoringMode
{
/// <summary>Item is created but neither sampling nor publishing.</summary>
Disabled = 0,
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
Sampling = 1,
/// <summary>Item samples and publishes — the OPC UA default.</summary>
Reporting = 2,
}
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
/// </summary>
/// <remarks>
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
///
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
/// </remarks>
public static class AbCipArrayReadPlanner
{
/// <summary>
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
/// <c>null</c> when the slice element type isn't supported under this declaration-only
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
/// scalar read path so the operator gets a clean per-element result instead.
/// </summary>
public static AbCipArrayReadPlan? TryBuild(
AbCipTagDefinition definition,
AbCipTagPath parsedPath,
AbCipTagCreateParams baseParams)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(parsedPath);
ArgumentNullException.ThrowIfNull(baseParams);
if (parsedPath.Slice is null) return null;
if (!TryGetStride(definition.DataType, out var stride)) return null;
var slice = parsedPath.Slice;
var createParams = baseParams with
{
TagName = parsedPath.ToLibplctagSliceArrayName(),
ElementCount = slice.Count,
};
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
}
/// <summary>
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
/// </summary>
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(runtime);
var values = new object?[plan.Slice.Count];
for (var i = 0; i < plan.Slice.Count; i++)
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
return values;
}
private static bool TryGetStride(AbCipDataType type, out int stride)
{
switch (type)
{
case AbCipDataType.SInt: case AbCipDataType.USInt:
stride = 1; return true;
case AbCipDataType.Int: case AbCipDataType.UInt:
stride = 2; return true;
case AbCipDataType.DInt: case AbCipDataType.UDInt:
case AbCipDataType.Real: case AbCipDataType.Dt:
stride = 4; return true;
case AbCipDataType.LInt: case AbCipDataType.ULInt:
case AbCipDataType.LReal:
stride = 8; return true;
default:
stride = 0; return false;
}
}
}
/// <summary>
/// Plan output: the libplctag create-params for the single array-read tag plus the
/// element-type / stride / slice metadata the decoder needs.
/// </summary>
public sealed record AbCipArrayReadPlan(
AbCipDataType ElementType,
AbCipTagPathSlice Slice,
int Stride,
AbCipTagCreateParams CreateParams);
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
AbCipDataType.Bool => DriverDataType.Boolean,
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
AbCipDataType.LInt => DriverDataType.Int64,
AbCipDataType.ULInt => DriverDataType.UInt64,
AbCipDataType.Real => DriverDataType.Float32,
AbCipDataType.LReal => DriverDataType.Float64,
AbCipDataType.String => DriverDataType.String,
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
_ => DriverDataType.Int32,
};
+254 -75
View File
@@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
TagPath: $"{tag.TagPath}.{member.Name}",
DataType: member.DataType,
Writable: member.Writable,
WriteIdempotent: member.WriteIdempotent);
WriteIdempotent: member.WriteIdempotent,
StringLength: member.StringLength);
_tagsByName[memberTag.Name] = memberTag;
}
}
@@ -357,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
// Rockwell array read; the contiguous buffer is decoded at element stride into a
// single snapshot whose Value is an object[] of the N elements.
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
if (parsedPath?.Slice is not null)
{
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
return;
}
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
@@ -372,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return;
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
var bitIndex = parsedPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
@@ -390,6 +401,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
/// </summary>
private async Task ReadSliceAsync(
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadNotSupported, null, now);
return;
}
try
{
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
.ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading slice {def.Name}");
return;
}
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
var runtime = _tagFactory.Create(createParams);
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[tagName] = runtime;
return runtime;
}
/// <summary>
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
@@ -451,100 +545,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IWritable ----
/// <summary>
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
/// and the resilience pipeline (layered above the driver) decides whether to replay.
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
/// plan decisions #44, #45, #143 the caller opts in via
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
/// above the driver) decides whether to replay. Non-writable configurations surface as
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
/// errors as <c>BadCommunicationError</c>.
/// </summary>
/// <remarks>
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
/// preserved in the caller's input order on partial failures.
/// </remarks>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var 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 (!_tagsByName.TryGetValue(w.FullReference, out var def))
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable || def.SafetyTag)
{
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
// Bit-RMW writes always serialise per-parent — never packed.
foreach (var entry in plan.BitRmw)
results[entry.OriginalIndex] = new WriteResult(
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
// per-parent lock prevents two concurrent bit writes to the same DINT from
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
if (plan.Packable.Count == 0) continue;
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
{
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
// into one Multi-Service Packet when the family supports it.
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
for (var i = 0; i < plan.Packable.Count; i++)
{
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
var entry = plan.Packable[i];
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
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);
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var (idx, code) in outcomes)
results[idx] = new WriteResult(code);
}
catch (OperationCanceledException)
else
{
throw;
}
catch (NotSupportedException nse)
{
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
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);
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
foreach (var entry in plan.Packable)
{
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
.ConfigureAwait(false);
results[entry.OriginalIndex] = new WriteResult(code.code);
}
}
}
return results;
}
/// <summary>
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
/// </summary>
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
var def = entry.Definition;
var w = entry.Request;
var now = DateTime.UtcNow;
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>
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
/// 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,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout));
Timeout: _options.Timeout,
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -92,6 +92,13 @@ public sealed record AbCipDeviceOptions(
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
/// write attempt failing at runtime.</param>
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
/// non-<see cref="AbCipDataType.String"/> types.</param>
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
@@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition(
bool Writable = true,
bool WriteIdempotent = false,
IReadOnlyList<AbCipStructureMember>? Members = null,
bool SafetyTag = false);
bool SafetyTag = false,
int? StringLength = null);
/// <summary>
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
@@ -112,7 +120,8 @@ public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? StringLength = null);
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
@@ -0,0 +1,112 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
/// device so the driver can submit one round of writes per device instead of looping
/// strictly serially across the whole batch. Honours the per-family
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
/// </summary>
/// <remarks>
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
/// not surface Multi-Service Packet construction at the API surface — but the underlying
/// native library packs concurrent operations against the same connection automatically
/// when the family's protocol supports it. Issuing the writes concurrently per device
/// therefore gives us the round-trip reduction described in #228 without having to drop to
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
/// unsafe.</para>
///
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
/// packing here because they need a serialised read-modify-write under the per-parent
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
/// on the same DINT would risk losing one another's update.</para>
/// </remarks>
internal static class AbCipMultiWritePlanner
{
/// <summary>
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
/// when the device supports packing.
/// </summary>
internal readonly record struct ClassifiedWrite(
int OriginalIndex,
WriteRequest Request,
AbCipTagDefinition Definition,
AbCipTagPath? ParsedPath,
bool IsBitRmw);
/// <summary>
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
/// DINT.
/// </summary>
internal sealed class DevicePlan
{
public required string DeviceHostAddress { get; init; }
public required AbCipPlcFamilyProfile Profile { get; init; }
public List<ClassifiedWrite> Packable { get; } = new();
public List<ClassifiedWrite> BitRmw { get; } = new();
}
/// <summary>
/// Build the per-device plan list. Entries are visited in input order so the resulting
/// plan's traversal preserves caller ordering within each device. Entries that fail
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
/// the plan.
/// </summary>
public static IReadOnlyList<DevicePlan> Build(
IReadOnlyList<WriteRequest> writes,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
Action<int, uint> reportPreflight)
{
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
var order = new List<DevicePlan>();
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!tagsByName.TryGetValue(w.FullReference, out var def))
{
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable || def.SafetyTag)
{
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
continue;
}
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
{
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
{
plan = new DevicePlan
{
DeviceHostAddress = def.DeviceHostAddress,
Profile = device.Profile,
};
plans[def.DeviceHostAddress] = plan;
order.Add(plan);
}
var parsed = AbCipTagPath.TryParse(def.TagPath);
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
if (isBitRmw) plan.BitRmw.Add(entry);
else plan.Packable.Add(entry);
}
return order;
}
}
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public sealed record AbCipTagPath(
string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex)
int? BitIndex,
AbCipTagPathSlice? Slice = null)
{
/// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName()
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
return buf.ToString();
}
/// <summary>
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
/// </summary>
public string ToLibplctagSliceArrayName()
{
if (Slice is null) return ToLibplctagName();
var buf = new System.Text.StringBuilder();
if (ProgramScope is not null)
buf.Append("Program:").Append(ProgramScope).Append('.');
for (var i = 0; i < Segments.Count; i++)
{
if (i > 0) buf.Append('.');
var seg = Segments[i];
buf.Append(seg.Name);
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
// "read N consecutive elements starting at index 0", which is the exact Rockwell
// array-read semantic this PR is wiring up.
buf.Append('[').Append(Slice.Start).Append(']');
return buf.ToString();
}
/// <summary>
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
/// doesn't support — the driver surfaces that as a config-validation error rather than
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
}
var segments = new List<AbCipTagPathSegment>(parts.Count);
foreach (var part in parts)
AbCipTagPathSlice? slice = null;
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
{
var part = parts[partIdx];
var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0)
{
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null;
var inner = part[(bracketIdx + 1)..^1];
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
// Any other shape is rejected so callers see a config-validation error rather than
// the driver attempting a best-effort scalar read.
if (inner.Contains(".."))
{
if (partIdx != parts.Count - 1) return null; // slice + sub-element
if (bitIndex is not null) return null; // slice + bit index
if (inner.Contains(',')) return null; // slice cannot be multi-dim
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
if (parts2.Length != 2) return null;
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
segments.Add(new AbCipTagPathSegment(name, []));
continue;
}
var subs = new List<int>();
foreach (var tok in inner.Split(','))
{
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
}
if (segments.Count == 0) return null;
return new AbCipTagPath(programScope, segments, bitIndex);
return new AbCipTagPath(programScope, segments, bitIndex, slice);
}
private static bool IsValidIdent(string s)
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
/// <summary>
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
/// Rockwell array read covering <c>End - Start + 1</c> elements.
/// </summary>
public sealed record AbCipTagPathSlice(int Start, int End)
{
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
public int Count => End - Start + 1;
}
@@ -65,10 +65,20 @@ public interface IAbCipTagFactory
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
/// behaviour for back-compat.</param>
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout);
TimeSpan Timeout,
int? StringMaxCapacity = null,
int? ElementCount = null);
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
Name = p.TagName,
Timeout = p.Timeout,
};
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
if (p.StringMaxCapacity is int cap && cap > 0)
_tag.StringMaxCapacity = (uint)cap;
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
// to issue one Rockwell array read for a [N..M] slice.
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
}
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset),
AbCipDataType.Dt => _tag.GetInt64(offset),
AbCipDataType.Structure => null,
_ => null,
};
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
break;
case AbCipDataType.Dt:
_tag.SetInt32(0, Convert.ToInt32(value));
_tag.SetInt64(0, Convert.ToInt64(value));
break;
case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
int? FileNumber,
int WordNumber,
int? BitIndex,
string? SubElement)
string? SubElement,
AbLegacyAddress? IndirectFileSource = null,
AbLegacyAddress? IndirectWordSource = null)
{
/// <summary>
/// True when either the file number or the word number is sourced from another PCCC
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
/// so the runtime layer must resolve the inner address first and rewrite the tag name
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
/// </summary>
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
public string ToLibplctagName()
{
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
var wordPart = $"{file}:{WordNumber}";
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
// accept the bracket form directly — callers that need a libplctag-ready name must
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
// path (TODO: resolve-then-read) is gated on IsIndirect.
string filePart;
if (IndirectFileSource is not null)
{
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
}
else
{
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
}
string wordSegment = IndirectWordSource is not null
? $"[{IndirectWordSource.ToLibplctagName()}]"
: WordNumber.ToString();
var wordPart = $"{filePart}:{wordSegment}";
if (SubElement is not null) wordPart += $".{SubElement}";
if (BitIndex is not null) wordPart += $"/{BitIndex}";
return wordPart;
}
public static AbLegacyAddress? TryParse(string? value)
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
/// <summary>
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
/// Pass the device's family so the parser can interpret those digits as octal when the
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
/// is what libplctag's PCCC layer expects.
/// </summary>
/// <remarks>
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
/// </remarks>
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
// BitIndex: trailing /N
int? bitIndex = null;
var slashIdx = src.IndexOf('/');
if (slashIdx >= 0)
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
string? bitText = null;
var slashIdx = src.LastIndexOf('/');
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
{
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
bitIndex = bit;
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
return ParseTail(src, bitText, profile, allowIndirect: true);
}
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
{
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
string? subElement = null;
var dotIdx = src.LastIndexOf('.');
var dotIdx = LastIndexOfTopLevel(src, '.');
if (dotIdx >= 0)
{
var candidate = src[(dotIdx + 1)..];
@@ -69,29 +123,139 @@ public sealed record AbLegacyAddress(
}
}
var colonIdx = src.IndexOf(':');
var colonIdx = IndexOfTopLevel(src, ':');
if (colonIdx <= 0) return null;
var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..];
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
// File letter (always literal) + optional file number — either decimal digits or a
// bracketed indirect address like N[N7:0].
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
var letterEnd = 1;
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
var letter = filePart[..letterEnd].ToUpperInvariant();
int? fileNumber = null;
AbLegacyAddress? indirectFile = null;
if (letterEnd < filePart.Length)
{
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
fileNumber = fn;
var fileTail = filePart[letterEnd..];
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
{
if (!allowIndirect) return null;
var inner = fileTail[1..^1];
indirectFile = ParseInner(inner, profile);
if (indirectFile is null) return null;
}
else
{
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
fileNumber = fn;
}
}
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
if (!IsKnownFileLetter(letter)) return null;
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
if (!IsKnownFileLetter(letter))
{
if (!IsFunctionFileLetter(letter) || profile?.SupportsFunctionFiles != true) return null;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
// indirect address.
int word = 0;
AbLegacyAddress? indirectWord = null;
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
{
if (!allowIndirect) return null;
var inner = wordPart[1..^1];
indirectWord = ParseInner(inner, profile);
if (indirectWord is null) return null;
}
else
{
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
}
int? bitIndex = null;
if (bitText is not null)
{
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
bitIndex = bit;
}
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
}
/// <summary>
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
/// must NOT be indirect — nesting beyond one level is rejected.
/// </summary>
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
{
if (string.IsNullOrWhiteSpace(inner)) return null;
var src = inner.Trim();
// Reject any further bracket — depth cap at 1.
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
string? bitText = null;
var slashIdx = src.LastIndexOf('/');
if (slashIdx >= 0)
{
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
return ParseTail(src, bitText, profile, allowIndirect: false);
}
private static int IndexOfTopLevel(string s, char c)
{
var depth = 0;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) return i;
}
return -1;
}
private static int LastIndexOfTopLevel(string s, char c)
{
var depth = 0;
var last = -1;
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '[') depth++;
else if (s[i] == ']') depth--;
else if (depth == 0 && s[i] == c) last = i;
}
return last;
}
private static bool TryParseIndex(string text, bool octal, out int value)
{
if (octal)
{
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
if (text.Length == 0) { value = 0; return false; }
var start = 0;
var sign = 1;
if (text[0] == '-') { sign = -1; start = 1; }
if (start >= text.Length) { value = 0; return false; }
var acc = 0;
for (var i = start; i < text.Length; i++)
{
var c = text[i];
if (c < '0' || c > '7') { value = 0; return false; }
acc = (acc * 8) + (c - '0');
}
value = sign * acc;
return true;
}
return int.TryParse(text, out value);
}
private static bool IsKnownFileLetter(string letter) => letter switch
@@ -99,4 +263,14 @@ public sealed record AbLegacyAddress(
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
_ => false,
};
/// <summary>
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
/// </summary>
internal static bool IsFunctionFileLetter(string letter) => letter switch
{
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
_ => false,
};
}
@@ -26,6 +26,72 @@ public enum AbLegacyDataType
CounterElement,
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
ControlElement,
/// <summary>
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
/// </summary>
MicroLogixFunctionFile,
}
/// <summary>
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
/// boundary so the driver never refuses a tag the customer happens to know about.
/// </summary>
public static class AbLegacyFunctionFile
{
/// <summary>
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
/// the driver permissive without forcing every quirk into the catalogue.
/// </summary>
public static DriverDataType SubElementType(string fileLetter, string? subElement)
{
if (subElement is null) return DriverDataType.Int32;
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
return key switch
{
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
// High-speed counter — accumulator/preset are Int32, status flags are bits.
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or
("HSC", "IN") or ("HSC", "IH") or ("HSC", "IL") or
("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
// Daylight saving + memory module info.
("DLS", "STR") or ("DLS", "STD") => DriverDataType.Int32,
("DLS", "EN") => DriverDataType.Boolean,
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
// Pulse-train / PWM output blocks.
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or
("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
// Selectable timed interrupt + event input interrupt.
("STI", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
// I/O status + base hardware info — mostly status flags + a few counters.
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
}
}
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
@@ -40,6 +106,106 @@ public static class AbLegacyDataTypeExtensions
AbLegacyDataType.String => DriverDataType.String,
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
/// <summary>
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
/// </summary>
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return t.ToDriverDataType();
var key = subElement.ToUpperInvariant();
return t switch
{
AbLegacyDataType.TimerElement => key switch
{
"EN" or "TT" or "DN" => DriverDataType.Boolean,
"PRE" or "ACC" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
AbLegacyDataType.CounterElement => key switch
{
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
"PRE" or "ACC" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
AbLegacyDataType.ControlElement => key switch
{
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
"LEN" or "POS" => DriverDataType.Int32,
_ => t.ToDriverDataType(),
},
_ => t.ToDriverDataType(),
};
}
/// <summary>
/// Bit position within the parent control word for Timer/Counter/Control status bits.
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
/// </summary>
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return null;
var key = subElement.ToUpperInvariant();
return t switch
{
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
AbLegacyDataType.TimerElement => key switch
{
"DN" => 13,
"TT" => 14,
"EN" => 15,
_ => null,
},
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
AbLegacyDataType.CounterElement => key switch
{
"UN" => 10,
"OV" => 11,
"DN" => 12,
"CD" => 13,
"CU" => 14,
_ => null,
},
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
AbLegacyDataType.ControlElement => key switch
{
"FD" => 8,
"IN" => 9,
"UL" => 10,
"ER" => 11,
"EM" => 12,
"DN" => 13,
"EU" => 14,
"EN" => 15,
_ => null,
},
_ => null,
};
}
/// <summary>
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
/// omitted so they keep default writable behaviour.
/// </summary>
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
{
if (subElement is null) return false;
var key = subElement.ToUpperInvariant();
return t switch
{
AbLegacyDataType.TimerElement => key is "DN" or "TT",
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
_ => false,
};
}
}
@@ -140,8 +140,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
var parsed = AbLegacyAddress.TryParse(def.Address);
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// Timer/Counter/Control status bits route through GetBit at the parent-word
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
// and pass it down to the runtime as a synthetic bitIndex.
var decodeBit = parsed?.BitIndex
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
var value = runtime.DecodeValue(def.DataType, decodeBit);
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
var parsed = AbLegacyAddress.TryParse(def.Address);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
// IN) are read-only — the PLC sets them; any client write would be silently
// overwritten on the next scan. Reject up front with BadNotWritable.
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
continue;
}
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
@@ -247,12 +261,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
// base element's Int32 to Boolean for status bits and Int32 for word members.
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
tag.DataType, parsed?.SubElement);
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
tag.DataType, parsed?.SubElement);
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
DriverDataType: effectiveType,
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
SecurityClass: tag.Writable && !plcSetBit
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
@@ -413,10 +434,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbLegacyAddress.TryParse(def.Address)
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
// rewriting the tag name with the resolved word number, then issuing the actual read.
// For now we surface a clear runtime error rather than letting libplctag fail with an
// opaque parser error.
if (parsed.IsIndirect)
throw new NotSupportedException(
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
/// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary>
public sealed record AbLegacyTagDefinition(
string Name,
@@ -40,8 +40,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
AbLegacyDataType.Long => _tag.GetInt32(0),
AbLegacyDataType.Float => _tag.GetFloat32(0),
AbLegacyDataType.String => _tag.GetString(0),
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
// decode as Int32 like before.
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
? _tag.GetBit(statusBit)
: _tag.GetInt32(0),
_ => null,
};
@@ -9,7 +9,9 @@ public sealed record AbLegacyPlcFamilyProfile(
string DefaultCipPath,
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
bool SupportsLongFile,
bool OctalIoAddressing,
bool SupportsFunctionFiles)
{
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{
@@ -25,21 +27,27 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+
SupportsLongFile: true); // L file available SLC 5/05+
SupportsLongFile: true, // L file available SLC 5/05+
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
SupportsFunctionFiles: false); // SLC500 has no function files
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232,
SupportsStringFile: true,
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
SupportsFunctionFiles: true); // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true,
SupportsLongFile: false); // PLC-5 predates L files
SupportsLongFile: false, // PLC-5 predates L files
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
SupportsFunctionFiles: false);
/// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -51,7 +59,9 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240,
SupportsStringFile: true,
SupportsLongFile: true);
SupportsLongFile: true,
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
SupportsFunctionFiles: false);
}
/// <summary>Which PCCC PLC family the device is.</summary>
@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
_ => int.MaxValue,
};
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
/// stays permissive (matches the modal / override fixed-tree precedent in
/// issue #259). Issue #260.
/// </summary>
public static bool SupportsTooling(FocasCncSeries series) => true;
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
/// policy as <see cref="SupportsTooling"/>. Issue #260.
/// </summary>
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
private static string? ValidateMacro(FocasCncSeries series, int number)
{
var (min, max) = MacroRange(series);
@@ -24,8 +24,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _toolingNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _messagesNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _currentBlockNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
/// deterministic discovery output.
/// </summary>
private static readonly string[] StatusFieldNames =
[
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
];
/// <summary>
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
/// seconds (issue #258). Order matters for deterministic discovery output.
/// </summary>
private static readonly string[] ProductionFieldNames =
[
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
];
/// <summary>
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
/// </summary>
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
/// <summary>
/// Names of the four operator-override child nodes per device — Feed / Rapid /
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
/// given field has the matching node omitted from the address space.
/// </summary>
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
/// <summary>
/// Names of the standard work-coordinate offset slots surfaced under
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
/// </summary>
private static readonly string[] WorkOffsetSlotNames =
[
"G54", "G55", "G56", "G57", "G58", "G59",
];
/// <summary>
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
/// </summary>
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
/// <summary>
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
/// counters surfaced for operator visibility (issue #262). Order matters for
/// deterministic discovery output.
/// <list type="bullet">
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
/// </list>
/// </summary>
private static readonly string[] DiagnosticsFieldNames =
[
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -76,6 +164,67 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_tagsByName[tag.Name] = tag;
}
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
foreach (var device in _devices.Values)
{
foreach (var field in StatusFieldNames)
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ProductionFieldNames)
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ModalFieldNames)
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
if (device.Options.OverrideParameters is { } op)
{
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(op, field) is null) continue;
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
}
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
// life + active offset index are deferred per the F1-d plan; they need
// ODBTLIFE* unions whose shape varies per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
{
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
device.Options.HostAddress;
}
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
// coordinate offsets (issue #260). Capability matrix gates by series so
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
{
foreach (var slot in WorkOffsetSlotNames)
foreach (var axis in WorkOffsetAxisNames)
{
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
(device.Options.HostAddress, slot, axis);
}
}
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
// tick (issue #261). Permissive across series (no capability gate yet).
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
// Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage,
// LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for
// operator visibility (issue #262). Permissive across all CNC series.
foreach (var field in DiagnosticsFieldNames)
_diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
@@ -113,6 +262,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
_devices.Clear();
_tagsByName.Clear();
_statusNodesByName.Clear();
_productionNodesByName.Clear();
_modalNodesByName.Clear();
_overrideNodesByName.Clear();
_toolingNodesByName.Clear();
_offsetNodesByName.Clear();
_messagesNodesByName.Clear();
_currentBlockNodesByName.Clear();
_diagnosticsNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -136,6 +294,73 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
// refreshed on the probe tick (issue #257). No wire call here.
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
{
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
continue;
}
// Fixed-tree Production/ nodes — served from the per-device cached production
// snapshot refreshed on the probe tick (issue #258). No wire call here.
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
{
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
continue;
}
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
{
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
continue;
}
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
{
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
continue;
}
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
// refreshed on the probe tick (issue #260). No wire call here.
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
{
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
continue;
}
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
// snapshot refreshed on the probe tick (issue #260). No wire call here.
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
{
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
continue;
}
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
{
results[i] = ReadMessagesLatestField(messagesHost, now);
continue;
}
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
{
results[i] = ReadCurrentBlockField(blockHost, now);
continue;
}
// Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe
// loop (issue #262). No wire call here.
if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey))
{
results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, now);
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -257,10 +482,267 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
var statusFolder = deviceFolder.Folder("Status", "Status");
foreach (var field in StatusFieldNames)
{
var fullRef = StatusReferenceFor(device.HostAddress, field);
statusFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
// + served from DeviceState.LastProduction.
var productionFolder = deviceFolder.Folder("Production", "Production");
foreach (var field in ProductionFieldNames)
{
var fullRef = ProductionReferenceFor(device.HostAddress, field);
productionFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int32,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
// per series + group (issue #259, plan PR F1-c).
var modalFolder = deviceFolder.Folder("Modal", "Modal");
foreach (var field in ModalFieldNames)
{
var fullRef = ModalReferenceFor(device.HostAddress, field);
modalFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
// OverrideParameters is null; per-field nodes whose parameter is null are
// omitted so a deployment can hide overrides their MTB doesn't wire up.
if (device.OverrideParameters is { } overrideParams)
{
var overrideFolder = deviceFolder.Folder("Override", "Override");
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(overrideParams, field) is null) continue;
var fullRef = OverrideReferenceFor(device.HostAddress, field);
overrideFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
{
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
FullName: toolingRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
// by series so legacy controllers without cnc_rdzofs support don't expose
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
{
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
foreach (var slot in WorkOffsetSlotNames)
{
var slotFolder = offsetsFolder.Folder(slot, slot);
foreach (var axis in WorkOffsetAxisNames)
{
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
// Fixed-tree Messages/External/Latest — single String node per device backed
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
// The issue body permits this minimal "latest message" surface in the first
// cut over a full ring-buffer of all four slots.
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
var externalFolder = messagesFolder.Folder("External", "External");
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
FullName: messagesRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
// Fixed-tree Program/CurrentBlock — single String node per device backed by
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
var programFolder = deviceFolder.Folder("Program", "Program");
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
FullName: blockRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
// Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for
// operator visibility (issue #262). ReadCount / ReadFailureCount /
// ReconnectCount are Int64; LastErrorMessage is String;
// LastSuccessfulRead is DateTime. Permissive across CNC series — every
// device gets the same shape.
var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics");
foreach (var field in DiagnosticsFieldNames)
{
var fullRef = DiagnosticsReferenceFor(device.HostAddress, field);
diagnosticsFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DiagnosticsFieldType(field),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
return Task.CompletedTask;
}
private static DriverDataType DiagnosticsFieldType(string field) => field switch
{
"ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64,
"LastErrorMessage" => DriverDataType.String,
"LastSuccessfulRead" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
private static string StatusReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Status/{field}";
private static string ProductionReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Production/{field}";
private static string ModalReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Modal/{field}";
private static string OverrideReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Override/{field}";
private static string ToolingReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Tooling/{field}";
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
$"{hostAddress}::Offsets/{slot}/{axis}";
private static string MessagesLatestReferenceFor(string hostAddress) =>
$"{hostAddress}::Messages/External/Latest";
private static string CurrentBlockReferenceFor(string hostAddress) =>
$"{hostAddress}::Program/CurrentBlock";
private static string DiagnosticsReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Diagnostics/{field}";
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{
"Feed" => p.FeedParam,
"Rapid" => p.RapidParam,
"Spindle" => p.SpindleParam,
"Jog" => p.JogParam,
_ => null,
};
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
{
"Tmmode" => s.Tmmode,
"Aut" => s.Aut,
"Run" => s.Run,
"Motion" => s.Motion,
"Mstb" => s.Mstb,
"EmergencyStop" => s.EmergencyStop,
"Alarm" => s.Alarm,
"Edit" => s.Edit,
"Dummy" => s.Dummy,
_ => null,
};
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
{
"PartsProduced" => p.PartsProduced,
"PartsRequired" => p.PartsRequired,
"PartsTotal" => p.PartsTotal,
"CycleTimeSeconds" => p.CycleTimeSeconds,
_ => null,
};
private static short? PickModalField(FocasModalInfo m, string field) => field switch
{
"MCode" => m.MCode,
"SCode" => m.SCode,
"TCode" => m.TCode,
"BCode" => m.BCode,
_ => null,
};
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
{
"Feed" => o.Feed,
"Rapid" => o.Rapid,
"Spindle" => o.Spindle,
"Jog" => o.Jog,
_ => null,
};
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
@@ -283,13 +765,123 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
while (!ct.IsCancellationRequested)
{
var success = false;
string? failureMessage = null;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
if (success)
{
// Refresh figure-scaling cache once per session (issue #262). The
// increment system rarely changes mid-session; re-reading every probe
// tick would waste a wire call. Best-effort — null result leaves the
// previous good map in place.
if (state.FigureScaling is null)
{
var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false);
if (fig is not null) state.FigureScaling = fig;
}
// Refresh the cached ODBST status snapshot on every probe tick — this is
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
// unchanged so the previous good snapshot keeps serving until refreshed.
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
if (snapshot is not null)
{
state.LastStatus = snapshot;
state.LastStatusUtc = DateTime.UtcNow;
}
// Refresh the cached production snapshot too — same best-effort policy
// as Status/: a null result leaves the previous good snapshot in place
// so reads keep serving until the next successful refresh (issue #258).
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
if (production is not null)
{
state.LastProduction = production;
state.LastProductionUtc = DateTime.UtcNow;
}
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
// policy as Status/ + Production/. Override snapshot is suppressed when
// the device has no OverrideParameters configured (issue #259).
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
if (modal is not null)
{
state.LastModal = modal;
state.LastModalUtc = DateTime.UtcNow;
}
if (state.Options.OverrideParameters is { } overrideParams)
{
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
if (ov is not null)
{
state.LastOverride = ov;
state.LastOverrideUtc = DateTime.UtcNow;
}
}
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
// effort policy as the other fixed-tree caches (issue #260). A
// null result leaves the previous good snapshot in place so reads
// keep serving until the next successful refresh.
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
{
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
if (tooling is not null)
{
state.LastTooling = tooling;
state.LastToolingUtc = DateTime.UtcNow;
}
}
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
{
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
if (offsets is not null)
{
state.LastWorkOffsets = offsets;
state.LastWorkOffsetsUtc = DateTime.UtcNow;
}
}
// Operator messages + currently-executing block — same best-effort
// policy as the other fixed-tree caches (issue #261). A null result
// leaves the previous good snapshot in place so reads keep serving
// until the next successful refresh.
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
if (messages is not null)
{
state.LastMessages = messages;
state.LastMessagesUtc = DateTime.UtcNow;
}
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
if (block is not null)
{
state.LastCurrentBlock = block;
state.LastCurrentBlockUtc = DateTime.UtcNow;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
catch (Exception ex)
{
failureMessage = ex.Message;
/* connect-failure path already disposed + cleared the client */
}
// Diagnostics counters refreshed per probe tick (issue #262). Successful
// ticks bump ReadCount + LastSuccessfulRead; failed ticks bump
// ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in
// EnsureConnectedAsync's connect path so a wedged probe doesn't double-count.
if (success)
{
Interlocked.Increment(ref state.ReadCount);
state.LastSuccessfulReadUtc = DateTime.UtcNow;
}
else
{
Interlocked.Increment(ref state.ReadFailureCount);
if (!string.IsNullOrEmpty(failureMessage))
state.LastErrorMessage = failureMessage;
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
@@ -298,6 +890,161 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastStatus is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickStatusField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastStatusUtc, now);
}
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastProduction is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickProductionField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
device.LastProductionUtc, now);
}
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastModal is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickModalField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastModalUtc, now);
}
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastOverride is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickOverrideField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastOverrideUtc, now);
}
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastTooling is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return field switch
{
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
device.LastToolingUtc, now),
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
};
}
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastWorkOffsets is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var match = snap.Offsets.FirstOrDefault(o =>
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
if (match is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
var value = axis switch
{
"X" => (double?)match.X,
"Y" => match.Y,
"Z" => match.Z,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
device.LastWorkOffsetsUtc, now);
}
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastMessages is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
// (most-recent) entry — the issue body permits this minimal "latest message"
// surface in lieu of a full ring buffer of all 4 classes.
var latest = snap.Messages.Count == 0
? string.Empty
: snap.Messages[snap.Messages.Count - 1].Text;
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
device.LastMessagesUtc, now);
}
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastCurrentBlock is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
device.LastCurrentBlockUtc, now);
}
private DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
// Diagnostics counters are always Good — they're driver-internal state, not wire
// reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe
// tick rather than null because OPC UA's DateTime variant has no "unset" sentinel
// a generic client can interpret (issue #262).
object? value = field switch
{
"ReadCount" => Interlocked.Read(ref device.ReadCount),
"ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount),
"ReconnectCount" => Interlocked.Read(ref device.ReconnectCount),
"LastErrorMessage" => device.LastErrorMessage ?? string.Empty,
"LastSuccessfulRead" => device.LastSuccessfulReadUtc,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
/// <summary>
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
/// device has a cached scaling entry for <paramref name="axisName"/> AND
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
/// don't need to re-derive the policy (issue #262).
/// </summary>
internal double ApplyFigureScaling(string hostAddress, string axisName, long raw)
{
if (!_options.FixedTree.ApplyFigureScaling) return raw;
if (!_devices.TryGetValue(hostAddress, out var device)) return raw;
if (device.FigureScaling is not { } map) return raw;
if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw;
return raw / Math.Pow(10.0, dec);
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -324,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
// Reconnect counter bumps before the connect call — a successful first connect
// counts as one "establishment" so the field is non-zero from session start
// (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics).
Interlocked.Increment(ref device.ReconnectCount);
device.Client ??= _clientFactory.Create();
try
{
@@ -352,6 +1103,90 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
/// the per-device <c>Status/&lt;field&gt;</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/&lt;field&gt;</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/&lt;field&gt;</c> nodes serve from this cache
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
/// </summary>
public FocasModalInfo? LastModal { get; set; }
public DateTime LastModalUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
/// </summary>
public FocasOverrideInfo? LastOverride { get; set; }
public DateTime LastOverrideUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
/// cache so they don't pile extra wire traffic on top of user-driven
/// reads (issue #260).
/// </summary>
public FocasToolingInfo? LastTooling { get; set; }
public DateTime LastToolingUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
/// offsets — refreshed on every probe tick. Reads of
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
/// </summary>
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
public DateTime LastWorkOffsetsUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
/// the four FANUC classes — refreshed on every probe tick. Reads of
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
/// </summary>
public FocasOperatorMessagesInfo? LastMessages { get; set; }
public DateTime LastMessagesUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
/// serve from this cache (issue #261).
/// </summary>
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
public DateTime LastCurrentBlockUtc { get; set; }
/// <summary>
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
/// Populated once per session (the increment system rarely changes mid-run);
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
/// surfaces position values that need scaling. Keys are axis names (or
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
/// </summary>
public IReadOnlyDictionary<string, int>? FigureScaling { get; set; }
// Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue
// #262). Public fields rather than properties so Interlocked.Increment can
// operate on them directly. Long-typed for the OPC UA Int64 surface.
public long ReadCount;
public long ReadFailureCount;
public long ReconnectCount;
public string? LastErrorMessage;
public DateTime LastSuccessfulReadUtc;
public void DisposeClient()
{
Client?.Dispose();
@@ -11,17 +11,49 @@ public sealed class FocasDriverOptions
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
/// decimal-place division applied to position values before publishing.
/// </summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new();
}
/// <summary>
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
/// deployments that already published raw scaled integers can flip this to <c>false</c>
/// for migration parity — the operator-facing concern is that switching the flag
/// mid-deployment changes the values clients see, so the migration path is
/// documentation-only (issue #262).
/// </summary>
public sealed record FocasFixedTreeOptions
{
/// <summary>
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
/// raw integer values are published unchanged — used for migrations from
/// older drivers that didn't apply the scaling.
/// </summary>
public bool ApplyFigureScaling { get; init; } = true;
}
/// <summary>
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
/// </summary>
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown);
FocasCncSeries Series = FocasCncSeries.Unknown,
FocasOverrideParameters? OverrideParameters = null);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
@@ -137,6 +137,256 @@ internal sealed class FwlibFocasClient : IFocasClient
return Task.FromResult(ret == 0);
}
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
var buf = new FwlibNative.ODBST();
var ret = FwlibNative.StatInfo(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
Dummy: buf.Dummy,
Tmmode: buf.TmMode,
Aut: buf.Aut,
Run: buf.Run,
Motion: buf.Motion,
Mstb: buf.Mstb,
EmergencyStop: buf.Emergency,
Alarm: buf.Alarm,
Edit: buf.Edit));
}
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
if (!TryReadInt32Param(6711, out var produced) ||
!TryReadInt32Param(6712, out var required) ||
!TryReadInt32Param(6713, out var total))
{
return Task.FromResult<FocasProductionInfo?>(null);
}
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
// — the parts counters are still useful even when cycle-time isn't supported.
var cycleSeconds = 0;
var tmrBuf = new FwlibNative.IODBTMR();
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
PartsProduced: produced,
PartsRequired: required,
PartsTotal: total,
CycleTimeSeconds: cycleSeconds));
}
private bool TryReadInt32Param(ushort number, out int value)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
if (ret != 0) { value = 0; return false; }
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
return true;
}
private bool TryReadInt16Param(ushort number, out short value)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
if (ret != 0) { value = 0; return false; }
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
return true;
}
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
// Best-effort — if any single read fails we still surface the others as 0; the
// probe loop only updates the cache on a non-null return so a partial snapshot
// is preferable to throwing away every successful field.
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
MCode: ReadModalAux(type: 100),
SCode: ReadModalAux(type: 101),
TCode: ReadModalAux(type: 102),
BCode: ReadModalAux(type: 103)));
}
private short ReadModalAux(short type)
{
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
if (ret != 0) return 0;
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
// record contract; oversized values would have been truncated by FWLIB anyway.
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
}
public Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
// Each parameter is independently nullable — a null parameter number keeps the
// corresponding field at null + skips the wire call. A successful read on at
// least one parameter is enough to publish a snapshot; this matches the
// best-effort policy used by GetProductionAsync (issue #259).
var feed = TryReadOverride(parameters.FeedParam);
var rapid = TryReadOverride(parameters.RapidParam);
var spindle = TryReadOverride(parameters.SpindleParam);
var jog = TryReadOverride(parameters.JogParam);
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
}
private short? TryReadOverride(ushort? param)
{
if (param is null) return null;
return TryReadInt16Param(param.Value, out var v) ? v : null;
}
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
var buf = new FwlibNative.IODBTNUM();
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
var t = buf.Data;
if (t > short.MaxValue) t = short.MaxValue;
else if (t < short.MinValue) t = short.MinValue;
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
}
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
// (the buffer ceiling) so a CNC with more axes still completes the call.
var slots = new List<FocasWorkOffset>(6);
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
for (short n = 1; n <= 6; n++)
{
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
if (ret != 0)
{
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
// still publishes so reads on the other offsets serve Good. The probe
// loop will retry on the next tick.
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
continue;
}
slots.Add(new FocasWorkOffset(
Name: names[n - 1],
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
}
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
}
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
// effort: a single-class failure leaves that class out of the snapshot rather
// than failing the whole call, mirroring GetProductionAsync's policy.
var list = new List<FocasOperatorMessage>(4);
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
for (short t = 0; t < 4; t++)
{
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
if (ret != 0) continue;
var text = TrimAnsiPadding(buf.Data);
if (string.IsNullOrEmpty(text)) continue;
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
}
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
}
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
var ret = FwlibNative.RdActPt(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
return Task.FromResult<FocasCurrentBlockInfo?>(
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
}
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
// kind=0 → position figures (absolute/relative/machine/distance share the same
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
// discovery in a follow-up. Issue #262, plan PR F1-f.
short count = 0;
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
}
/// <summary>
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
/// changing the cache contract (issue #262).
/// </summary>
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
{
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < clamped; i++)
{
var offset = i * 8;
if (offset + 2 > data.Length) break;
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
if (dec < 0 || dec > 9) dec = 0;
result[$"axis{i + 1}"] = dec;
}
return result;
}
/// <summary>
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
/// address space stays stable (issue #261). Stops at the first NUL so any wire
/// buffer that gets reused doesn't leak old bytes.
/// </summary>
internal static string TrimAnsiPadding(byte[] data)
{
if (data is null) return string.Empty;
var len = 0;
for (; len < data.Length; len++)
if (data[len] == 0) break;
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
}
/// <summary>
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
/// </summary>
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
{
const int blockSize = 10;
var offset = axisIndex * blockSize;
if (offset + blockSize > data.Length) return 0;
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
if (dec < 0 || dec > 9) dec = 0;
return raw / Math.Pow(10.0, dec);
}
// ---- PMC ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
@@ -88,6 +88,104 @@ internal static class FwlibNative
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
public static extern short StatInfo(ushort handle, ref ODBST buffer);
// ---- Timers ----
/// <summary>
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
/// so the surface can grow without another P/Invoke.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
// ---- Modal codes ----
/// <summary>
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
/// varies by group + series (issue #259).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
// ---- Tooling ----
/// <summary>
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
/// unions whose shape varies per series.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
// ---- Work coordinate offsets ----
/// <summary>
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
public static extern short RdWorkOffset(
ushort handle,
short number,
short axis,
short length,
ref IODBZOFS buffer);
// ---- Operator messages ----
/// <summary>
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern short RdOpMsg3(
ushort handle,
short type,
short length,
ref OPMSG3 buffer);
// ---- Figure (per-axis decimal scaling) ----
/// <summary>
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
/// (issue #262, plan PR F1-f).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
public static extern short GetFigure(
ushort handle,
short kind,
ref short outCount,
ref IODBAXIS figureinfo);
// ---- Currently-executing block ----
/// <summary>
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
/// reply struct holds the program / sequence numbers + the active block as a
/// null-padded ASCII string. Issue #261, plan PR F1-e.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
// ---- Structs ----
/// <summary>
@@ -129,6 +227,121 @@ internal static class FwlibNative
public short DecVal; // decimal-point count
}
/// <summary>
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBTMR
{
public int Minute;
public int Msec;
}
/// <summary>
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBMDL
{
public short Datano;
public short Type;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
public byte[] Data;
}
/// <summary>
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
/// <c>Int16</c>). Issue #260, F1-d.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBTNUM
{
public short Datano;
public short Type;
public int Data;
}
/// <summary>
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBZOFS
{
public short Datano;
public short Type;
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
public byte[] Data;
}
/// <summary>
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
/// for the longest documented operator message body (issue #261).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct OPMSG3
{
public short Datano;
public short Type;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
public byte[] Data;
}
/// <summary>
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
/// <c>char data[256]</c> (active block text). The text is null-terminated +
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBACTPT
{
public int ONo;
public int NNo;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
public byte[] Data;
}
/// <summary>
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
/// of the deployment's axis count (issue #262).
/// </summary>
public const int MAX_AXIS = 8;
/// <summary>
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
/// that axis's increment system + a unit code. The managed side reads the first
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
/// (issue #262, plan PR F1-f).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBAXIS
{
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
public byte[] Data;
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST
@@ -48,8 +48,236 @@ public interface IFocasClient : IDisposable
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
/// supply the struct (e.g. transport/IPC variant where the contract has not been
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
/// </summary>
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasStatusInfo?>(null);
/// <summary>
/// Read the per-CNC production counters (parts produced / required / total via
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
/// the cache untouched and the per-field nodes report Bad until the first refresh.
/// </summary>
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasProductionInfo?>(null);
/// <summary>
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
/// issue body permits surfacing only the universally-present M/S/T/B fields in
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
/// supply the snapshot.
/// </summary>
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasModalInfo?>(null);
/// <summary>
/// Read the four operator override values (feed / rapid / spindle / jog) via
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
/// field's read (the corresponding node is also omitted from the address space).
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
/// </summary>
Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
=> Task.FromResult<FocasOverrideInfo?>(null);
/// <summary>
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
/// client cannot supply the snapshot (e.g. older transport variant).
/// </summary>
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasToolingInfo?>(null);
/// <summary>
/// Read the standard G54..G59 work-coordinate offsets via
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
/// cannot supply the snapshot.
/// </summary>
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
/// <summary>
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
/// The call returns up to 4 active messages per class; the driver collapses the
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
/// Trailing nulls / spaces are trimmed before publishing so the same message
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
/// supply the snapshot (older transport variant).
/// </summary>
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
/// <summary>
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
/// The call returns the active block of the running program; surfaced as
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
/// wire client cannot supply the snapshot.
/// </summary>
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
/// <summary>
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
/// reports for that axis's increment system. Cached at bootstrap by the driver +
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
/// transport variant) — the driver leaves the cache untouched and falls back to
/// publishing raw values.
/// </summary>
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
}
/// <summary>
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
/// exactly what the CNC reported.
/// </summary>
public sealed record FocasStatusInfo(
short Dummy,
short Tmmode,
short Aut,
short Run,
short Motion,
short Mstb,
short EmergencyStop,
short Alarm,
short Edit);
/// <summary>
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
/// <c>Int32</c> in the OPC UA address space.
/// </summary>
public sealed record FocasProductionInfo(
int PartsProduced,
int PartsRequired,
int PartsTotal,
int CycleTimeSeconds);
/// <summary>
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
/// the issue body permits the first cut to surface only the universally-present
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
/// </summary>
public sealed record FocasModalInfo(
short MCode,
short SCode,
short TCode,
short BCode);
/// <summary>
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
/// wire up rather than always serving Bad.
/// </summary>
public sealed record FocasOverrideParameters(
ushort? FeedParam,
ushort? RapidParam,
ushort? SpindleParam,
ushort? JogParam)
{
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
}
/// <summary>
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
/// </summary>
public sealed record FocasOverrideInfo(
short? Feed,
short? Rapid,
short? Spindle,
short? Jog);
/// <summary>
/// Snapshot of the currently selected tool number (issue #260). Sourced from
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
/// interleave tool number and offset H/D codes through different FOCAS calls
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
/// the OPC UA address space.
/// </summary>
public sealed record FocasToolingInfo(short CurrentTool);
/// <summary>
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
/// mills typically don't expose extended rotational offsets via the standard
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
/// </summary>
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
/// <summary>
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
/// the probe tick + served from the per-device cache by reads of the
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
/// </summary>
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
/// <summary>
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
/// for the four message types. <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so round-trips through the OPC UA address space stay stable
/// (issue #261).
/// </summary>
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
/// <summary>
/// Snapshot of all active FANUC operator messages across the four message
/// classes (issue #261). Surfaced under the FOCAS driver's
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
/// message in the list is what gets published. Empty list means the CNC
/// reported no active messages; the node publishes an empty string in that
/// case.
/// </summary>
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
/// <summary>
/// Snapshot of the currently-executing program block text via
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so the same block round-trips with stable text. Surfaced
/// as a String node at <c>Program/CurrentBlock</c>.
/// </summary>
public sealed record FocasCurrentBlockInfo(string Text);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
@@ -75,6 +75,31 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
/// </summary>
private SessionReconnectHandler? _reconnectHandler;
/// <summary>
/// Cached server-advertised OperationLimits, fetched lazily on first batch op and
/// refreshed on reconnect. Null until the first successful fetch; null components
/// mean "fetch hasn't completed yet, fall through to single-call". Per spec, a 0
/// limit means "no limit" — we surface that as <c>uint?</c>=null too so the
/// chunking helper has a single sentinel for "don't chunk".
/// </summary>
private OperationLimitsCache? _operationLimits;
private readonly SemaphoreSlim _operationLimitsLock = new(1, 1);
/// <summary>
/// Snapshot of the four OperationLimits the driver chunks against. Stored as
/// <c>uint?</c> so callers can distinguish "not yet fetched" / "no limit"
/// (null) from "limit = N" (Some(N)). Spec sentinel 0 is normalized to null at
/// fetch time so the chunking helper has a single "don't chunk" sentinel.
/// </summary>
internal sealed record OperationLimitsCache(
uint? MaxNodesPerRead,
uint? MaxNodesPerWrite,
uint? MaxNodesPerBrowse,
uint? MaxNodesPerHistoryReadData);
/// <summary>Test seam — exposes the cached limits so unit tests can assert fetch behaviour.</summary>
internal OperationLimitsCache? OperationLimitsForTest => _operationLimits;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "OpcUaClient";
@@ -427,6 +452,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
try { Session?.Dispose(); } catch { }
Session = null;
_connectedEndpointUrl = null;
_operationLimits = null;
TransitionTo(HostState.Unknown);
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
@@ -442,6 +468,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new DataValueSnapshot[fullReferences.Count];
var now = DateTime.UtcNow;
@@ -463,31 +490,44 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerRead — chunk large batches so a single ReadAsync stays
// under the cap. cap=null means "no limit" (sentinel for both 0-from-server and
// not-yet-fetched), in which case ChunkBy yields the input as a single slice and
// the wire path collapses to one SDK call.
var readCap = _operationLimits?.MaxNodesPerRead;
var indexMapList = indexMap; // close over for catch
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: toSend,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, readCap))
{
var r = indexMap[w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
var chunkColl = new ReadValueIdCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
{
var r = indexMapList[wireOffset + w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
}
wireOffset += chunk.Count;
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -497,9 +537,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
// BadInternalError) so operators distinguish "upstream unreachable" from
// "driver bug".
for (var w = 0; w < indexMap.Count; w++)
for (var w = 0; w < indexMapList.Count; w++)
{
var r = indexMap[w];
var r = indexMapList[w];
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
@@ -515,6 +555,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new WriteResult[writes.Count];
var toSend = new WriteValueCollection();
@@ -537,24 +578,34 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerWrite — same chunking pattern as ReadAsync. cap=null
// collapses to a single wire call.
var writeCap = _operationLimits?.MaxNodesPerWrite;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: toSend,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, writeCap))
{
var r = indexMap[w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
var chunkColl = new WriteValueCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
{
var r = indexMap[wireOffset + w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
}
wireOffset += chunk.Count;
}
}
catch (Exception)
@@ -591,6 +642,81 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
private ISession RequireSession() =>
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
/// <summary>
/// Lazily fetch <c>Server.ServerCapabilities.OperationLimits</c> from the upstream
/// server and cache them on the driver. Idempotent — called from every batch op,
/// no-ops once a successful fetch has populated the cache. The cache is cleared on
/// reconnect (see <see cref="OnReconnectComplete"/>) so a server with redrawn
/// capabilities doesn't run forever with stale caps.
/// </summary>
/// <remarks>
/// Uses <see cref="Session.FetchOperationLimitsAsync(CancellationToken)"/> when the
/// active session is a concrete <see cref="Session"/> (always true in production —
/// the SDK's session factory returns Session). Falls back gracefully on any fetch
/// failure: callers see <see cref="_operationLimits"/> remain null and fall through
/// to single-call behaviour. Per OPC UA Part 5, a server reporting 0 for any
/// OperationLimits attribute means "no limit"; we normalize that to <c>null</c> so
/// the chunking helper has a single sentinel.
/// </remarks>
private async Task EnsureOperationLimitsFetchedAsync(CancellationToken ct)
{
if (_operationLimits is not null) return;
await _operationLimitsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_operationLimits is not null) return;
if (Session is not Session concrete) return;
try
{
await concrete.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
var ol = concrete.OperationLimits;
if (ol is null) return;
_operationLimits = new OperationLimitsCache(
MaxNodesPerRead: NormalizeLimit(ol.MaxNodesPerRead),
MaxNodesPerWrite: NormalizeLimit(ol.MaxNodesPerWrite),
MaxNodesPerBrowse: NormalizeLimit(ol.MaxNodesPerBrowse),
MaxNodesPerHistoryReadData: NormalizeLimit(ol.MaxNodesPerHistoryReadData));
}
catch
{
// Fetch failed — leave cache null so we re-attempt on the next batch op.
// Single-call behaviour applies in the meantime; never block traffic on a
// capability discovery glitch.
}
}
finally { _operationLimitsLock.Release(); }
}
/// <summary>Spec sentinel: 0 = "no limit". Normalize to null for the chunking helper.</summary>
private static uint? NormalizeLimit(uint raw) => raw == 0 ? null : raw;
/// <summary>
/// Split <paramref name="source"/> into contiguous slices of at most <paramref name="cap"/>
/// items. Returns the input as a single slice when the cap is null (no limit),
/// 0, or larger than the input — the spec sentinel + the no-cap path collapse onto
/// the same single-call branch so the wire path stays a single SDK invocation when
/// the server doesn't impose a limit.
/// </summary>
internal static IEnumerable<ArraySegment<T>> ChunkBy<T>(IReadOnlyList<T> source, uint? cap)
{
if (source.Count == 0) yield break;
var array = source as T[] ?? source.ToArray();
if (cap is null or 0 || (uint)array.Length <= cap.Value)
{
yield return new ArraySegment<T>(array, 0, array.Length);
yield break;
}
var size = checked((int)cap.Value);
for (var offset = 0; offset < array.Length; offset += size)
{
var len = Math.Min(size, array.Length - offset);
yield return new ArraySegment<T>(array, offset, len);
}
}
// ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
@@ -851,29 +977,43 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// ---- ISubscribable ----
public async Task<ISubscriptionHandle> SubscribeAsync(
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
// Route the simple-string overload through the per-tag overload with all knobs at
// their defaults. Single code path for subscription create — keeps the wire-side
// identical for callers that don't need per-tag tuning.
var specs = new MonitoredTagSpec[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
specs[i] = new MonitoredTagSpec(fullReferences[i]);
return SubscribeAsync(specs, publishingInterval, cancellationToken);
}
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<MonitoredTagSpec> tags, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var session = RequireSession();
var id = Interlocked.Increment(ref _nextSubscriptionId);
var handle = new OpcUaSubscriptionHandle(id);
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
// bandwidth on every subscription create.
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
? 50
// Floor the publishing interval — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-floor values wastes
// negotiation bandwidth on every subscription create. Floor is configurable via
// OpcUaSubscriptionDefaults.MinPublishingIntervalMs (default 50ms).
var subDefaults = _options.Subscriptions;
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(subDefaults.MinPublishingIntervalMs)
? subDefaults.MinPublishingIntervalMs
: (int)publishingInterval.TotalMilliseconds;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = (uint)subDefaults.KeepAliveCount,
LifetimeCount = subDefaults.LifetimeCount,
MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
Priority = subDefaults.Priority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -883,29 +1023,28 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
foreach (var fullRef in fullReferences)
foreach (var spec in tags)
{
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = fullRef,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = intervalMs,
QueueSize = 1,
DiscardOldest = true,
})
{
Handle = fullRef,
};
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(item);
if (!TryParseNodeId(session, spec.TagName, out var nodeId)) continue;
var monItem = BuildMonitoredItem(spec, nodeId, intervalMs);
monItem.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(monItem);
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
try
{
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Opc.Ua.ServiceResultException sre)
{
// PercentDeadband requires the server to expose EURange on the variable; if
// it isn't set the server returns BadFilterNotAllowed during item creation.
// We swallow the exception here so other items in the batch still get created
// — per-item failure surfaces through MonitoredItem.Status.Error rather than
// tearing down the whole subscription.
if (sre.StatusCode != StatusCodes.BadFilterNotAllowed) throw;
}
_subscriptions[id] = new RemoteSubscription(subscription, handle);
}
finally { _gate.Release(); }
@@ -913,6 +1052,84 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
return handle;
}
/// <summary>
/// Map a <see cref="MonitoredTagSpec"/> to a SDK <see cref="MonitoredItem"/> with the
/// per-tag knobs applied. Defaults match the original hard-coded values
/// (Reporting / SamplingInterval=publishInterval / QueueSize=1 / DiscardOldest=true)
/// so a spec with all knobs <c>null</c> behaves identically to the legacy path.
/// </summary>
internal static MonitoredItem BuildMonitoredItem(MonitoredTagSpec spec, NodeId nodeId, int defaultIntervalMs)
{
var sampling = spec.SamplingIntervalMs.HasValue ? (int)spec.SamplingIntervalMs.Value : defaultIntervalMs;
var queueSize = spec.QueueSize ?? 1u;
var discardOldest = spec.DiscardOldest ?? true;
var monitoringMode = spec.MonitoringMode is { } mm ? MapMonitoringMode(mm) : MonitoringMode.Reporting;
var filter = BuildDataChangeFilter(spec.DataChangeFilter);
var options = new MonitoredItemOptions
{
DisplayName = spec.TagName,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = monitoringMode,
SamplingInterval = sampling,
QueueSize = queueSize,
DiscardOldest = discardOldest,
Filter = filter,
};
return new MonitoredItem(telemetry: null!, options)
{
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
Handle = spec.TagName,
};
}
/// <summary>
/// Build the OPC UA <see cref="DataChangeFilter"/> from a <see cref="DataChangeFilterSpec"/>,
/// or return <c>null</c> if the caller didn't supply a filter. PercentDeadband requires
/// server-side EURange — if the server rejects with BadFilterNotAllowed, the caller's
/// <c>SubscribeAsync</c> swallows it so other items in the batch still get created.
/// </summary>
internal static DataChangeFilter? BuildDataChangeFilter(DataChangeFilterSpec? spec)
{
if (spec is null) return null;
return new DataChangeFilter
{
Trigger = MapTrigger(spec.Trigger),
DeadbandType = (uint)MapDeadbandType(spec.DeadbandType),
DeadbandValue = spec.DeadbandValue,
};
}
/// <summary>Map our SDK-free <see cref="SubscriptionMonitoringMode"/> to the OPC UA SDK's enum.</summary>
internal static MonitoringMode MapMonitoringMode(SubscriptionMonitoringMode mode) => mode switch
{
SubscriptionMonitoringMode.Disabled => MonitoringMode.Disabled,
SubscriptionMonitoringMode.Sampling => MonitoringMode.Sampling,
SubscriptionMonitoringMode.Reporting => MonitoringMode.Reporting,
_ => MonitoringMode.Reporting,
};
/// <summary>Map our <see cref="Core.Abstractions.DataChangeTrigger"/> to the SDK enum.</summary>
internal static Opc.Ua.DataChangeTrigger MapTrigger(Core.Abstractions.DataChangeTrigger trigger) => trigger switch
{
Core.Abstractions.DataChangeTrigger.Status => Opc.Ua.DataChangeTrigger.Status,
Core.Abstractions.DataChangeTrigger.StatusValue => Opc.Ua.DataChangeTrigger.StatusValue,
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp => Opc.Ua.DataChangeTrigger.StatusValueTimestamp,
_ => Opc.Ua.DataChangeTrigger.StatusValue,
};
/// <summary>Map our <see cref="Core.Abstractions.DeadbandType"/> to the SDK enum.</summary>
internal static Opc.Ua.DeadbandType MapDeadbandType(Core.Abstractions.DeadbandType type) => type switch
{
Core.Abstractions.DeadbandType.None => Opc.Ua.DeadbandType.None,
Core.Abstractions.DeadbandType.Absolute => Opc.Ua.DeadbandType.Absolute,
Core.Abstractions.DeadbandType.Percent => Opc.Ua.DeadbandType.Percent,
_ => Opc.Ua.DeadbandType.None,
};
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaSubscriptionHandle h) return;
@@ -975,15 +1192,16 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
// match in O(1) without re-parsing on every event.
var sourceFilter = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
var alarmDefaults = _options.Subscriptions;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-alarm-sub-{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = (uint)alarmDefaults.KeepAliveCount,
LifetimeCount = alarmDefaults.LifetimeCount,
MaxNotificationsPerPublish = alarmDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
Priority = alarmDefaults.AlarmsPriority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -1345,6 +1563,10 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
}
Session = newSession;
// Drop cached OperationLimits so the next batch op refetches against the (potentially
// re-redeployed) upstream server. A zero-cost guard against a server whose published
// capabilities changed across the reconnect window.
_operationLimits = null;
_reconnectHandler?.Dispose();
_reconnectHandler = null;
@@ -1380,5 +1602,6 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
_operationLimitsLock.Dispose();
}
}
@@ -134,8 +134,58 @@ public sealed class OpcUaClientDriverOptions
/// browse forever.
/// </summary>
public int MaxBrowseDepth { get; init; } = 10;
/// <summary>
/// Per-subscription tuning knobs applied when the driver creates data + alarm
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
/// values so existing deployments see no behaviour change.
/// </summary>
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
}
/// <summary>
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
/// </summary>
/// <param name="KeepAliveCount">
/// Number of consecutive empty publish cycles before the server sends a keep-alive
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
/// client notices a stalled subscription within ~5x the publish interval.
/// </param>
/// <param name="LifetimeCount">
/// Number of consecutive missed publish responses before the server tears down the
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
/// </param>
/// <param name="MaxNotificationsPerPublish">
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
/// </param>
/// <param name="Priority">
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
/// </param>
/// <param name="MinPublishingIntervalMs">
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
/// will only round up anyway. Default 50ms.
/// </param>
/// <param name="AlarmsPriority">
/// Subscription priority for the alarm subscription (0..255). Higher than
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
/// data-tag bursts.
/// </param>
public sealed record OpcUaSubscriptionDefaults(
int KeepAliveCount = 10,
uint LifetimeCount = 1000,
uint MaxNotificationsPerPublish = 0,
byte Priority = 0,
int MinPublishingIntervalMs = 50,
byte AlarmsPriority = 1);
/// <summary>OPC UA message security mode.</summary>
public enum OpcUaSecurityMode
{
@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipArrayReadPlannerTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagCreateParams BaseParams(string tagName) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: "1,0",
LibplctagPlcAttribute: "controllogix",
TagName: tagName,
Timeout: TimeSpan.FromSeconds(5));
[Fact]
public void TryBuild_emits_single_tag_create_with_element_count()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
plan.ShouldNotBeNull();
plan.ElementType.ShouldBe(AbCipDataType.DInt);
plan.Stride.ShouldBe(4);
plan.Slice.Count.ShouldBe(16);
plan.CreateParams.ElementCount.ShouldBe(16);
// Anchored at the slice start; libplctag reads N consecutive elements from there.
plan.CreateParams.TagName.ShouldBe("Data[0]");
}
[Fact]
public void TryBuild_returns_null_when_path_has_no_slice()
{
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
[InlineData(AbCipDataType.Structure)]
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.SInt, 1)]
[InlineData(AbCipDataType.Int, 2)]
[InlineData(AbCipDataType.DInt, 4)]
[InlineData(AbCipDataType.Real, 4)]
[InlineData(AbCipDataType.LInt, 8)]
[InlineData(AbCipDataType.LReal, 8)]
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
plan.Stride.ShouldBe(expectedStride);
}
[Fact]
public void Decode_walks_buffer_at_element_stride()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
fake.ValuesByOffset[0] = 100;
fake.ValuesByOffset[4] = 200;
fake.ValuesByOffset[8] = 300;
fake.ValuesByOffset[12] = 400;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.Length.ShouldBe(4);
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
}
[Fact]
public void Decode_preserves_slice_count_for_real_arrays()
{
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
fake.ValuesByOffset[0] = 1.5f;
fake.ValuesByOffset[4] = 2.5f;
fake.ValuesByOffset[8] = 3.5f;
fake.ValuesByOffset[12] = 4.5f;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
}
}
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter");
}
[Fact]
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
{
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
// buffer decoded at element stride into one snapshot whose Value is an object?[].
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p =>
{
var t = new FakeAbCipTag(p);
t.ValuesByOffset[0] = 10;
t.ValuesByOffset[4] = 20;
t.ValuesByOffset[8] = 30;
t.ValuesByOffset[12] = 40;
return t;
};
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
// Exactly ONE libplctag tag was created — anchored at the slice start with
// ElementCount=4. Without the planner this would have been four scalar reads.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Data[0]");
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
{
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
// of attempting a best-effort decode.
var (drv, _) = NewDriver(
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Cancellation_propagates_from_read()
{
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue();
}
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
[Fact]
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
StringLength: 40));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
await drv.ReadAsync(["Banner"], CancellationToken.None);
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
}
[Fact]
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task StringLength_ignored_for_non_String_data_types()
{
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
// declared DataType.
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
StringLength: 80));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
{
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
// runtime then receives the right StringMaxCapacity.
var udt = new AbCipTagDefinition(
Name: "Recipe",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Recipe",
DataType: AbCipDataType.Structure,
Members: [
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
new AbCipStructureMember("Code", AbCipDataType.DInt),
]);
var (drv, factory) = NewDriver(udt);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
}
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
{
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
}
}
@@ -0,0 +1,283 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-1.4 — multi-tag write packing. Validates that <see cref="AbCipDriver.WriteAsync"/>
/// groups writes by device, dispatches packable writes for request-packing-capable
/// families concurrently, falls back to sequential writes on Micro800, keeps BOOL-RMW
/// writes on the per-parent semaphore path, and fans per-tag StatusCodes out to the
/// correct positions on partial failures.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipMultiWritePackingTests
{
[Fact]
public async Task Writes_get_grouped_by_device()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
new AbCipTagDefinition("A1", "ab://10.0.0.5/1,0", "A1", AbCipDataType.DInt),
new AbCipTagDefinition("A2", "ab://10.0.0.5/1,0", "A2", AbCipDataType.DInt),
new AbCipTagDefinition("B1", "ab://10.0.0.6/1,0", "B1", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A1", 1),
new WriteRequest("B1", 100),
new WriteRequest("A2", 2),
], CancellationToken.None);
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Per-device handles materialised — A1/A2 share device A, B1 lives on device B.
factory.Tags["A1"].CreationParams.Gateway.ShouldBe("10.0.0.5");
factory.Tags["A2"].CreationParams.Gateway.ShouldBe("10.0.0.5");
factory.Tags["B1"].CreationParams.Gateway.ShouldBe("10.0.0.6");
factory.Tags["A1"].WriteCount.ShouldBe(1);
factory.Tags["A2"].WriteCount.ShouldBe(1);
factory.Tags["B1"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task ControlLogix_packs_concurrently_within_a_device()
{
// ControlLogix has SupportsRequestPacking=true → a multi-write batch is dispatched in
// parallel. The fake's WriteAsync gates on a TaskCompletionSource so we can prove that
// both writes are in flight at the same time before either completes.
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var inFlight = 0;
var maxInFlight = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p => new GatedWriteFake(p, gate, () =>
{
var current = Interlocked.Increment(ref inFlight);
var observed = maxInFlight;
while (current > observed
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
observed = maxInFlight;
}, () => Interlocked.Decrement(ref inFlight)),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var writeTask = drv.WriteAsync(
[
new WriteRequest("A", 1),
new WriteRequest("B", 2),
new WriteRequest("C", 3),
], CancellationToken.None);
// Wait until all three writes have entered WriteAsync simultaneously, then release.
await WaitForAsync(() => Volatile.Read(ref inFlight) >= 3, TimeSpan.FromSeconds(2));
gate.SetResult(0);
var results = await writeTask;
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
maxInFlight.ShouldBeGreaterThanOrEqualTo(2,
"ControlLogix supports request packing — packable writes should run concurrently within the device.");
}
[Fact]
public async Task Micro800_falls_back_to_sequential_writes()
{
// Micro800 has SupportsRequestPacking=false → writes go one-at-a-time; the gated fake
// never sees more than one in-flight at a time.
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
gate.SetResult(0); // No need to gate — we just observe concurrency.
var inFlight = 0;
var maxInFlight = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p => new GatedWriteFake(p, gate, () =>
{
var current = Interlocked.Increment(ref inFlight);
var observed = maxInFlight;
while (current > observed
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
observed = maxInFlight;
}, () => Interlocked.Decrement(ref inFlight)),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/", AbCipPlcFamily.Micro800)],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/", "B", AbCipDataType.DInt),
new AbCipTagDefinition("C", "ab://10.0.0.5/", "C", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", 1),
new WriteRequest("B", 2),
new WriteRequest("C", 3),
], CancellationToken.None);
results.Count.ShouldBe(3);
results.ShouldAllBe(r => r.StatusCode == AbCipStatusMapper.Good);
maxInFlight.ShouldBe(1,
"Micro800 disables request packing — writes must execute sequentially.");
}
[Fact]
public async Task Bit_in_dint_writes_still_route_through_RMW_path()
{
// BOOL-with-bitIndex must hit the per-parent RMW semaphore — it must NOT go through
// the packable per-tag runtime path. We prove this by checking that:
// (a) the per-tag "bit-selector" runtime is never created (it would throw via
// LibplctagTagRuntime's NotSupportedException had the bypass happened);
// (b) the parent-DINT runtime got both a Read and a Write.
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool),
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("Flag3", true),
new WriteRequest("Speed", 99),
], CancellationToken.None);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Parent runtime created lazily for Flags (no .3 suffix) — drove the RMW.
factory.Tags.ShouldContainKey("Flags");
factory.Tags["Flags"].ReadCount.ShouldBe(1);
factory.Tags["Flags"].WriteCount.ShouldBe(1);
// Speed went through the packable path.
factory.Tags["Speed"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task Per_tag_status_code_fan_out_works_on_partial_failure()
{
// Mix Good + BadTimeout + BadNotWritable + BadNodeIdUnknown across two devices to
// exercise the original-index preservation through the per-device plan + concurrent
// dispatch.
var factory = new FakeAbCipTagFactory
{
Customise = p => p.TagName == "B"
? new FakeAbCipTag(p) { Status = -5 /* timeout */ }
: new FakeAbCipTag(p),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
new AbCipTagDefinition("RO", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false),
new AbCipTagDefinition("C", "ab://10.0.0.6/1,0", "C", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", 1),
new WriteRequest("B", 2),
new WriteRequest("RO", 3),
new WriteRequest("UnknownTag", 4),
new WriteRequest("C", 5),
], CancellationToken.None);
results.Count.ShouldBe(5);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
results[3].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
results[4].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!predicate())
{
if (DateTime.UtcNow >= deadline)
throw new TimeoutException("predicate did not become true within timeout");
await Task.Delay(10).ConfigureAwait(false);
}
}
/// <summary>
/// Test fake whose <see cref="WriteAsync"/> blocks on a shared
/// <see cref="TaskCompletionSource"/> so the test can observe how many writes are
/// simultaneously in flight inside the driver.
/// </summary>
private sealed class GatedWriteFake : FakeAbCipTag
{
private readonly TaskCompletionSource<int> _gate;
private readonly Action _onEnter;
private readonly Action _onExit;
public GatedWriteFake(AbCipTagCreateParams p, TaskCompletionSource<int> gate,
Action onEnter, Action onExit) : base(p)
{
_gate = gate;
_onEnter = onEnter;
_onExit = onExit;
}
public override async Task WriteAsync(CancellationToken ct)
{
_onEnter();
try
{
await _gate.Task.ConfigureAwait(false);
await base.WriteAsync(ct).ConfigureAwait(false);
}
finally
{
_onExit();
}
}
}
}
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
[Fact]
public void Slice_basic_inclusive_range()
{
var p = AbCipTagPath.TryParse("Data[0..15]");
p.ShouldNotBeNull();
p.Slice.ShouldNotBeNull();
p.Slice!.Start.ShouldBe(0);
p.Slice.End.ShouldBe(15);
p.Slice.Count.ShouldBe(16);
p.BitIndex.ShouldBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBeEmpty();
p.ToLibplctagName().ShouldBe("Data[0..15]");
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
// index; pair with ElementCount to cover the whole range.
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
}
[Fact]
public void Slice_with_program_scope_and_member_chain()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
p.Slice!.Start.ShouldBe(3);
p.Slice.End.ShouldBe(7);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
}
[Fact]
public void Slice_zero_length_single_element_allowed()
{
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
var p = AbCipTagPath.TryParse("Data[5..5]");
p.ShouldNotBeNull();
p.Slice!.Count.ShouldBe(1);
}
[Theory]
[InlineData("Data[5..3]")] // M < N
[InlineData("Data[-1..5]")] // negative start
[InlineData("Data[0..15].Member")] // slice + sub-element
[InlineData("Data[0..15].3")] // slice + bit index
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
[InlineData("Data[..5]")] // missing start
[InlineData("Data[5..]")] // missing end
[InlineData("Data[a..5]")] // non-numeric start
public void Invalid_slice_shapes_return_null(string input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
@@ -1,6 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
@@ -65,4 +66,271 @@ public sealed class AbLegacyAddressTests
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe(input);
}
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
//
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
// Non-I/O file letters are always decimal regardless of family.
[Theory]
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(expectedWord);
a.BitIndex.ShouldBe(expectedBit);
}
[Theory]
[InlineData("I:8/0")] // word digit 8 illegal in octal
[InlineData("I:0/9")] // bit digit 9 illegal in octal
[InlineData("O:128/0")] // contains digit 8
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
}
[Theory]
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
[InlineData("N7:8", 7, 8)]
[InlineData("F8:9", 8, 9)]
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.FileNumber.ShouldBe(expectedFile);
a.WordNumber.ShouldBe(expectedWord);
}
[Fact]
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
{
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(10);
a.BitIndex.ShouldBe(15);
// Decimal '8' that PLC-5 would reject is fine on SLC500.
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
b.ShouldNotBeNull();
b.WordNumber.ShouldBe(8);
}
[Fact]
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
{
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
}
[Fact]
public void Plc5Profile_advertises_octal_io_addressing()
{
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
}
// ---- MicroLogix function-file letters (Issue #245) ----
//
// MicroLogix 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI function files. Other
// PCCC families (SLC500 / PLC-5 / LogixPccc) reject those file letters.
[Theory]
[InlineData("RTC:0.HR", "RTC", "HR")]
[InlineData("RTC:0.MIN", "RTC", "MIN")]
[InlineData("RTC:0.YR", "RTC", "YR")]
[InlineData("HSC:0.ACC", "HSC", "ACC")]
[InlineData("HSC:0.PRE", "HSC", "PRE")]
[InlineData("HSC:0.EN", "HSC", "EN")]
[InlineData("DLS:0.STR", "DLS", "STR")]
[InlineData("PTO:0.OF", "PTO", "OF")]
[InlineData("PWM:0.EN", "PWM", "EN")]
[InlineData("STI:0.SPM", "STI", "SPM")]
[InlineData("EII:0.PFN", "EII", "PFN")]
[InlineData("MMI:0.FT", "MMI", "FT")]
[InlineData("BHI:0.OS", "BHI", "OS")]
[InlineData("IOS:0.ID", "IOS", "ID")]
public void TryParse_MicroLogix_accepts_function_files(string input, string expectedLetter, string expectedSub)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix);
a.ShouldNotBeNull();
a.FileLetter.ShouldBe(expectedLetter);
a.SubElement.ShouldBe(expectedSub);
}
[Theory]
[InlineData("RTC:0.HR")]
[InlineData("HSC:0.ACC")]
[InlineData("PTO:0.OF")]
[InlineData("BHI:0.OS")]
public void TryParse_Slc500_rejects_function_files(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
}
[Theory]
[InlineData("RTC:0.HR")]
[InlineData("HSC:0.ACC")]
public void TryParse_Plc5_and_LogixPccc_reject_function_files(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
}
[Fact]
public void TryParse_Default_overload_rejects_function_files()
{
// Without a family the parser cannot allow MicroLogix-only letters — back-compat with
// the family-less overload from before #244.
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
AbLegacyAddress.TryParse("HSC:0.ACC").ShouldBeNull();
}
[Fact]
public void MicroLogixProfile_advertises_function_file_support()
{
AbLegacyPlcFamilyProfile.MicroLogix.SupportsFunctionFiles.ShouldBeTrue();
AbLegacyPlcFamilyProfile.Slc500.SupportsFunctionFiles.ShouldBeFalse();
AbLegacyPlcFamilyProfile.Plc5.SupportsFunctionFiles.ShouldBeFalse();
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
}
// ---- Indirect / indexed addressing (Issue #247) ----
//
// PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and
// `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the
// inner address must itself be a plain direct PCCC reference.
[Fact]
public void TryParse_accepts_indirect_word_source()
{
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
a.ShouldNotBeNull();
a.FileLetter.ShouldBe("N");
a.FileNumber.ShouldBe(7);
a.IndirectFileSource.ShouldBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.FileLetter.ShouldBe("N");
a.IndirectWordSource.FileNumber.ShouldBe(7);
a.IndirectWordSource.WordNumber.ShouldBe(0);
a.IsIndirect.ShouldBeTrue();
}
[Fact]
public void TryParse_accepts_indirect_file_source()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
a.ShouldNotBeNull();
a.FileLetter.ShouldBe("N");
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(5);
a.IndirectFileSource.ShouldNotBeNull();
a.IndirectFileSource!.FileLetter.ShouldBe("N");
a.IndirectFileSource.FileNumber.ShouldBe(7);
a.IndirectFileSource.WordNumber.ShouldBe(0);
a.IndirectWordSource.ShouldBeNull();
a.IsIndirect.ShouldBeTrue();
}
[Fact]
public void TryParse_accepts_both_indirect_file_and_word()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]");
a.ShouldNotBeNull();
a.IndirectFileSource.ShouldNotBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.WordNumber.ShouldBe(1);
}
[Theory]
[InlineData("N[N[N7:0]:0]:5")] // depth-2 file source
[InlineData("N7:[N[N7:0]:0]")] // depth-2 word source
[InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word)
public void TryParse_rejects_depth_greater_than_one(string input)
{
AbLegacyAddress.TryParse(input).ShouldBeNull();
}
[Theory]
[InlineData("N7:[")] // unbalanced bracket
[InlineData("N7:]")] // unbalanced bracket
[InlineData("N[:5")] // empty inner file source
[InlineData("N7:[]")] // empty inner word source
[InlineData("N[X9:0]:5")] // unknown file letter inside
public void TryParse_rejects_malformed_indirect(string input)
{
AbLegacyAddress.TryParse(input).ShouldBeNull();
}
[Fact]
public void ToLibplctagName_reemits_indirect_word_source()
{
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N7:[N7:0]");
}
[Fact]
public void ToLibplctagName_reemits_indirect_file_source()
{
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N[N7:0]:5");
}
[Fact]
public void TryParse_indirect_with_bit_outside_brackets()
{
// Outer bit applies to the resolved word; inner address is still depth-1.
var a = AbLegacyAddress.TryParse("N7:[N7:0]/3");
a.ShouldNotBeNull();
a.BitIndex.ShouldBe(3);
a.IndirectWordSource.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe("N7:[N7:0]/3");
}
[Fact]
public void TryParse_Plc5_indirect_inner_address_obeys_octal()
{
// Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets.
var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.IndirectWordSource.ShouldNotBeNull();
a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8
a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8
// Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5.
AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull();
}
[Fact]
public void TryParse_indirect_inner_cannot_itself_be_indirect()
{
AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull();
AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull();
}
[Theory]
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("HSC", "ACC", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("HSC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("DLS", "STR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
[InlineData("DLS", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
[InlineData("PWM", "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
public void FunctionFile_subelement_catalogue_maps_to_expected_driver_type(
string letter, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
{
AbLegacyFunctionFile.SubElementType(letter, sub).ShouldBe(expected);
}
}
@@ -102,4 +102,96 @@ public sealed class AbLegacyDriverTests
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "EN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "TT", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.TimerElement, "PRE", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.TimerElement, "ACC", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.CounterElement, "CU", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "CD", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "OV", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "UN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.CounterElement, "PRE", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.CounterElement, "ACC", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.ControlElement, "EN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "EU", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "DN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "EM", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "ER", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "UL", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "IN", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "FD", DriverDataType.Boolean)]
[InlineData(AbLegacyDataType.ControlElement, "LEN", DriverDataType.Int32)]
[InlineData(AbLegacyDataType.ControlElement, "POS", DriverDataType.Int32)]
public void EffectiveDriverDataType_resolves_subelements(
AbLegacyDataType dataType, string subElement, DriverDataType expected)
{
AbLegacyDataTypeExtensions.EffectiveDriverDataType(dataType, subElement).ShouldBe(expected);
}
[Fact]
public void EffectiveDriverDataType_unknown_subelement_falls_back_to_base()
{
// Permissive — keeps the driver from refusing tags whose sub-element we don't catalogue.
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, "BOGUS")
.ShouldBe(DriverDataType.Int32);
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, null)
.ShouldBe(DriverDataType.Int32);
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.Int, "DN")
.ShouldBe(DriverDataType.Int32);
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "DN", 13)]
[InlineData(AbLegacyDataType.TimerElement, "TT", 14)]
[InlineData(AbLegacyDataType.TimerElement, "EN", 15)]
[InlineData(AbLegacyDataType.CounterElement, "UN", 10)]
[InlineData(AbLegacyDataType.CounterElement, "OV", 11)]
[InlineData(AbLegacyDataType.CounterElement, "DN", 12)]
[InlineData(AbLegacyDataType.CounterElement, "CD", 13)]
[InlineData(AbLegacyDataType.CounterElement, "CU", 14)]
[InlineData(AbLegacyDataType.ControlElement, "FD", 8)]
[InlineData(AbLegacyDataType.ControlElement, "IN", 9)]
[InlineData(AbLegacyDataType.ControlElement, "UL", 10)]
[InlineData(AbLegacyDataType.ControlElement, "ER", 11)]
[InlineData(AbLegacyDataType.ControlElement, "EM", 12)]
[InlineData(AbLegacyDataType.ControlElement, "DN", 13)]
[InlineData(AbLegacyDataType.ControlElement, "EU", 14)]
[InlineData(AbLegacyDataType.ControlElement, "EN", 15)]
public void StatusBitIndex_maps_to_standard_pccc_positions(
AbLegacyDataType dataType, string subElement, int expectedBit)
{
AbLegacyDataTypeExtensions.StatusBitIndex(dataType, subElement).ShouldBe(expectedBit);
}
[Fact]
public void StatusBitIndex_for_word_subelements_is_null()
{
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, "PRE").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.CounterElement, "ACC").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.ControlElement, "LEN").ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, null).ShouldBeNull();
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.Int, "DN").ShouldBeNull();
}
[Theory]
[InlineData(AbLegacyDataType.TimerElement, "DN", true)]
[InlineData(AbLegacyDataType.TimerElement, "TT", true)]
[InlineData(AbLegacyDataType.TimerElement, "EN", false)] // operator-controllable
[InlineData(AbLegacyDataType.CounterElement, "DN", true)]
[InlineData(AbLegacyDataType.CounterElement, "OV", true)]
[InlineData(AbLegacyDataType.CounterElement, "UN", true)]
[InlineData(AbLegacyDataType.CounterElement, "CU", false)]
[InlineData(AbLegacyDataType.ControlElement, "DN", true)]
[InlineData(AbLegacyDataType.ControlElement, "ER", true)]
[InlineData(AbLegacyDataType.ControlElement, "EM", true)]
[InlineData(AbLegacyDataType.ControlElement, "EN", false)]
public void IsPlcSetStatusBit_classifies_writable_vs_status_bits(
AbLegacyDataType dataType, string subElement, bool expected)
{
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(dataType, subElement).ShouldBe(expected);
}
}
@@ -256,4 +256,113 @@ public sealed class AbLegacyReadWriteTests
Value = value;
}
}
// ---- Timer / Counter / Control sub-element bit semantics (issue #246) ----
[Theory]
[InlineData("T4:0.DN", 13)]
[InlineData("T4:0.TT", 14)]
[InlineData("T4:0.EN", 15)]
public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed a parent-word with only the target bit set.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
// The driver must have asked the runtime for the right bit position.
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Fact]
public async Task Timer_PRE_subelement_decodes_as_int_word()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 };
var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(5000);
factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull();
}
[Theory]
[InlineData("C5:0.UN", 10)]
[InlineData("C5:0.OV", 11)]
[InlineData("C5:0.DN", 12)]
[InlineData("C5:0.CD", 13)]
[InlineData("C5:0.CU", 14)]
public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Theory]
[InlineData("R6:0.FD", 8)]
[InlineData("R6:0.IN", 9)]
[InlineData("R6:0.UL", 10)]
[InlineData("R6:0.ER", 11)]
[InlineData("R6:0.EM", 12)]
[InlineData("R6:0.DN", 13)]
[InlineData("R6:0.EU", 14)]
[InlineData("R6:0.EN", 15)]
public async Task Control_status_bit_decodes_correct_position(string address, int bitPos)
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(true);
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
}
[Fact]
public async Task Status_bit_returns_false_when_parent_word_bit_is_clear()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement));
await drv.InitializeAsync("{}", CancellationToken.None);
// Bit 14 (TT) set, bit 13 (DN) clear.
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 };
var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(false);
}
[Theory]
[InlineData("T4:0.DN", AbLegacyDataType.TimerElement)]
[InlineData("T4:0.TT", AbLegacyDataType.TimerElement)]
[InlineData("C5:0.DN", AbLegacyDataType.CounterElement)]
[InlineData("C5:0.OV", AbLegacyDataType.CounterElement)]
[InlineData("C5:0.UN", AbLegacyDataType.CounterElement)]
[InlineData("R6:0.ER", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.EM", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.DN", AbLegacyDataType.ControlElement)]
[InlineData("R6:0.FD", AbLegacyDataType.ControlElement)]
public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable(
string address, AbLegacyDataType dataType)
{
var (drv, _) = NewDriver(
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
}
}
@@ -40,7 +40,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
}
public virtual int GetStatus() => Status;
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
public int? LastDecodeBitIndex { get; private set; }
public AbLegacyDataType? LastDecodeType { get; private set; }
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex)
{
LastDecodeType = type;
LastDecodeBitIndex = bitIndex;
// If the test seeded a parent-word value (ushort/short/int) and the driver asked for a
// specific status bit, mask it out so we can assert the correct bit reaches the client.
if (bitIndex is int bit && Value is not null and not bool)
{
try
{
var word = Convert.ToInt32(Value);
return ((word >> bit) & 1) != 0;
}
catch (Exception ex) when (ex is FormatException or InvalidCastException) { }
}
return Value;
}
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
public virtual void Dispose() => Disposed = true;
}
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
// parent folders — disambiguate by FullName, which is unique per node.
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- ISubscribable ----
@@ -0,0 +1,273 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasFigureScalingDiagnosticsTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// per-axis figure scaling for the F1-f cache + diagnostics surface
/// (issue #262).
/// </summary>
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
Task<IReadOnlyDictionary<string, int>?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) =>
Task.FromResult(Scaling);
}
[Fact]
public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-diag", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics");
var diagVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Diagnostics/")).ToList();
diagVars.Count.ShouldBe(5);
// Verify per-field types match the documented surface (Int64 counters,
// String error message, DateTime last-success timestamp).
diagVars.Single(v => v.BrowseName == "ReadCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "ReadFailureCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "ReconnectCount")
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
diagVars.Single(v => v.BrowseName == "LastErrorMessage")
.Info.DriverDataType.ShouldBe(DriverDataType.String);
diagVars.Single(v => v.BrowseName == "LastSuccessfulRead")
.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
foreach (var v in diagVars)
v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks()
{
// Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead;
// ReconnectCount bumps once on the initial connect (issue #262).
var fake = new FakeFocasClient { ProbeResult = true };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-diag-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 2;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Diagnostics/ReadCount",
$"{Host}::Diagnostics/ReadFailureCount",
$"{Host}::Diagnostics/ReconnectCount",
$"{Host}::Diagnostics/LastErrorMessage",
$"{Host}::Diagnostics/LastSuccessfulRead",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2);
((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe
((long)snaps[2].Value!).ShouldBe(1); // one initial connect
snaps[3].Value.ShouldBe(string.Empty);
((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false()
{
// ProbeResult=false → success branch is skipped, ReadFailureCount bumps each
// tick. The connect itself succeeded so ReconnectCount is 1.
var fake = new FakeFocasClient { ProbeResult = false };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-diag-fail", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 2;
}, TimeSpan.FromSeconds(3));
var snaps = await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"],
CancellationToken.None);
((long)snaps[0].Value!).ShouldBe(0);
((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places()
{
// Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling
// default is true → rawValue / 10^dec for the named axis (issue #262).
var fake = new FigureAwareFakeFocasClient
{
Scaling = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000)
["axis2"] = 4, // Y-axis: 4 decimal places
},
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
}, "drv-fig", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for the probe-tick path to populate the cache (one successful tick is
// enough — the figure-scaling read happens whenever the cache is null).
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 1;
}, TimeSpan.FromSeconds(3));
// 100000 / 10^3 = 100.0 mm
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0);
// 250000 / 10^4 = 25.0 mm
drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0);
// Unknown axis → raw value passes through.
drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false()
{
// ApplyFigureScaling=false short-circuits before the cache lookup so the raw
// integer is published unchanged. Migration parity for deployments that already
// surfaced raw values from older drivers (issue #262).
var fake = new FigureAwareFakeFocasClient
{
Scaling = new Dictionary<string, int> { ["axis1"] = 3 },
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false },
}, "drv-fig-off", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
return snap.Value is long n && n >= 1;
}, TimeSpan.FromSeconds(3));
// Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false
// means the raw value passes through unchanged.
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke so the driver leaves the cache untouched.
var client = new FwlibFocasClient();
(await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer()
{
// Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per
// fwlib32.h each axis entry is { short dec, short unit, short reserved,
// short reserved2 } = 8 bytes; we only read dec.
var buf = new byte[FwlibNative.MAX_AXIS * 8];
// Axis 1: dec=3
buf[0] = 3; buf[1] = 0;
// Axis 2: dec=4
buf[8] = 4; buf[9] = 0;
// Axis 3: dec=0 (already zero)
var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3);
map.Count.ShouldBe(3);
map["axis1"].ShouldBe(3);
map["axis2"].ShouldBe(4);
map["axis3"].ShouldBe(0);
// Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't
// overrun the buffer.
var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99);
clamped.Count.ShouldBe(FwlibNative.MAX_AXIS);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -0,0 +1,231 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasMessagesBlockTextFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
/// fixed-tree (issue #261).
/// </summary>
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasOperatorMessagesInfo? Messages { get; set; }
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
Task.FromResult(Messages);
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
Task.FromResult(CurrentBlock);
}
[Fact]
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
var latest = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Messages/External/Latest");
latest.BrowseName.ShouldBe("Latest");
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
var block = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Program/CurrentBlock");
block.BrowseName.ShouldBe("CurrentBlock");
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
{
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo(
[
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
]),
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Messages/External/Latest",
$"{Host}::Program/CurrentBlock",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
snaps[0].Value.ShouldBe("DOOR OPEN");
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither cache populates; the nodes still resolve as known
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
// policy.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg-empty", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
{
// Empty snapshot (CNC reported no active messages) still publishes Good +
// empty string — operators distinguish "no messages" from "Bad" without
// having to read separate availability nodes.
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo([]),
CurrentBlock = new FocasCurrentBlockInfo(""),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-empty-snap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].Value.ShouldBe(string.Empty);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe(string.Empty);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c/f1d.
var client = new FwlibFocasClient();
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
{
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
// managed side trims them so the same message round-trips with stable text
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
var buf = new byte[16];
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
Array.Copy(bytes, buf, bytes.Length);
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
// at the NUL so leftover bytes in the rest of the buffer are ignored.
var buf2 = new byte[32];
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
Array.Copy(bytes2, buf2, bytes2.Length);
// After NUL the buffer has zeros — already invisible — but explicit space
// padding before the NUL should be trimmed.
var buf3 = new byte[32];
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
Array.Copy(bytes3, buf3, bytes3.Length);
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
// Empty buffer → empty string (no exception).
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -0,0 +1,231 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasModalOverrideFixedTreeTests
{
private const string Host = "focas://10.0.0.6:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
/// </summary>
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasModalInfo? Modal { get; set; }
public FocasOverrideInfo? Override { get; set; }
public FocasOverrideParameters? LastOverrideParams { get; private set; }
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
Task.FromResult(Modal);
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken ct)
{
LastOverrideParams = parameters;
return Task.FromResult(Override);
}
}
[Fact]
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-modal", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
var modalVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Modal/")).ToList();
modalVars.Count.ShouldBe(4);
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
foreach (var name in expected)
{
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
}
}
[Fact]
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-no-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
}
[Fact]
public async Task DiscoverAsync_emits_only_configured_Override_fields()
{
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-partial-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
var overrideVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Override/")).ToList();
overrideVars.Count.ShouldBe(2);
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
}
[Fact]
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
{
var fake = new ModalAwareFakeFocasClient
{
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-modal-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate both caches.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Modal/MCode",
$"{Host}::Modal/SCode",
$"{Host}::Modal/TCode",
$"{Host}::Modal/BCode",
$"{Host}::Override/Feed",
$"{Host}::Override/Rapid",
$"{Host}::Override/Spindle",
$"{Host}::Override/Jog",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)8);
snaps[1].Value.ShouldBe((short)1200);
snaps[2].Value.ShouldBe((short)101);
snaps[3].Value.ShouldBe((short)0);
snaps[4].Value.ShouldBe((short)100);
snaps[5].Value.ShouldBe((short)50);
snaps[6].Value.ShouldBe((short)110);
snaps[7].Value.ShouldBe((short)25);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
// The driver hands the device's configured override parameters to the wire client
// verbatim — defaulting to 30i numbers.
fake.LastOverrideParams.ShouldNotBeNull();
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither modal nor override caches populate; the nodes still
// resolve as known references but report Bad until the first successful poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-cache", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must short-
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
var client = new FwlibFocasClient();
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetOverrideAsync(
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -0,0 +1,156 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasProductionFixedTreeTests
{
private const string Host = "focas://10.0.0.5:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
/// </summary>
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasProductionInfo? Production { get; set; }
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
Task.FromResult(Production);
}
[Fact]
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
var prodVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Production/")).ToList();
prodVars.Count.ShouldBe(4);
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
foreach (var name in expected)
{
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
}
}
[Fact]
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
{
var fake = new ProductionAwareFakeFocasClient
{
Production = new FocasProductionInfo(
PartsProduced: 17,
PartsRequired: 100,
PartsTotal: 4242,
CycleTimeSeconds: 73),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate the cache.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Production/PartsProduced"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Production/PartsProduced",
$"{Host}::Production/PartsRequired",
$"{Host}::Production/PartsTotal",
$"{Host}::Production/CycleTimeSeconds",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe(17);
snaps[1].Value.ShouldBe(100);
snaps[2].Value.ShouldBe(4242);
snaps[3].Value.ShouldBe(73);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
{
// Probe disabled — cache never populates; the production nodes still resolve as
// known references but report Bad until the first successful poll lands.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Production/PartsProduced"], CancellationToken.None);
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); calling GetProductionAsync on the
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
// in its current state.
var client = new FwlibFocasClient();
var result = await client.GetProductionAsync(CancellationToken.None);
result.ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -0,0 +1,197 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasStatusFixedTreeTests
{
private const string Host = "focas://10.0.0.5:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
/// keeps its existing boolean semantic so the back-compat path stays exercised.
/// </summary>
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasStatusInfo? Status { get; set; }
// Shadow the default interface implementation with a real one. Explicit interface
// form so callers via IFocasClient hit this override; FakeFocasClient itself
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
Task.FromResult(Status);
}
[Fact]
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
var statusVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Status/")).ToList();
statusVars.Count.ShouldBe(9);
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
foreach (var name in expected)
{
var node = statusVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Status/{name}");
}
}
[Fact]
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
{
var fake = new StatusAwareFakeFocasClient
{
Status = new FocasStatusInfo(
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate the cache.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Status/Tmmode",
$"{Host}::Status/Aut",
$"{Host}::Status/Run",
$"{Host}::Status/Motion",
$"{Host}::Status/Mstb",
$"{Host}::Status/EmergencyStop",
$"{Host}::Status/Alarm",
$"{Host}::Status/Edit",
$"{Host}::Status/Dummy",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)1); // Tmmode
snaps[1].Value.ShouldBe((short)2); // Aut
snaps[2].Value.ShouldBe((short)3); // Run
snaps[3].Value.ShouldBe((short)4); // Motion
snaps[4].Value.ShouldBe((short)5); // Mstb
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
snaps[6].Value.ShouldBe((short)7); // Alarm
snaps[7].Value.ShouldBe((short)6); // Edit
snaps[8].Value.ShouldBe((short)0); // Dummy
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
{
// Probe disabled — cache never populates; the status nodes still resolve as
// known references but report Bad until the first successful poll lands.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None);
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
{
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
// but the probe still flips host state to Running.
var fake = new FakeFocasClient { ProbeResult = true };
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => Task.FromResult(
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
TimeSpan.FromSeconds(3));
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
// but the rest of the driver keeps functioning.
var snap = (await drv.ReadAsync(
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
// in its current state.
var client = new FwlibFocasClient();
var result = await client.GetStatusAsync(CancellationToken.None);
result.ShouldBeNull();
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -0,0 +1,220 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasToolingOffsetsFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
/// </summary>
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasToolingInfo? Tooling { get; set; }
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
Task.FromResult(Tooling);
Task<FocasWorkOffsetsInfo?> IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) =>
Task.FromResult(WorkOffsets);
}
[Fact]
public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling");
var toolingVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Tooling/")).ToList();
toolingVars.Count.ShouldBe(1);
var node = toolingVars.Single();
node.BrowseName.ShouldBe("CurrentTool");
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool");
}
[Fact]
public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes()
{
// Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per
// device. Extended G54.1 P1..P48 deferred per the F1-d plan.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-offsets", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Offsets");
string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"];
foreach (var slot in expectedSlots)
builder.Folders.ShouldContain(f => f.BrowseName == slot);
var offsetVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Offsets/")).ToList();
offsetVars.Count.ShouldBe(6 * 3);
foreach (var slot in expectedSlots)
foreach (var axis in new[] { "X", "Y", "Z" })
{
var fullRef = $"{Host}::Offsets/{slot}/{axis}";
var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef);
node.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
}
[Fact]
public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot()
{
var fake = new ToolingAwareFakeFocasClient
{
Tooling = new FocasToolingInfo(CurrentTool: 17),
WorkOffsets = new FocasWorkOffsetsInfo(
[
new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0),
new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3),
]),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-tooling-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate both caches.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Tooling/CurrentTool",
$"{Host}::Offsets/G54/X",
$"{Host}::Offsets/G54/Y",
$"{Host}::Offsets/G54/Z",
$"{Host}::Offsets/G59/X",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)17);
snaps[1].Value.ShouldBe(100.5);
snaps[2].Value.ShouldBe(200.25);
snaps[3].Value.ShouldBe(-50.0);
snaps[4].Value.ShouldBe(1.0);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither tooling nor offsets caches populate; the nodes
// still resolve as known references but report Bad until the first poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c.
var client = new FwlibFocasClient();
(await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode()
{
// Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes.
// Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0).
var buf = new byte[80];
WriteAxis(buf, 0, raw: 12345, dec: 3);
WriteAxis(buf, 1, raw: -500, dec: 2);
WriteAxis(buf, 2, raw: 0, dec: 0);
FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9);
}
private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec)
{
var offset = axisIndex * 10;
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw);
System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -67,6 +67,45 @@ public sealed class OpcUaClientDriverScaffoldTests
health.LastError.ShouldNotBeNull();
}
[Fact]
public void Default_subscription_tuning_matches_prior_hard_coded_values()
{
// PR #273: lifted hard-coded Subscription parameters into options; defaults MUST
// remain wire-identical so existing deployments see no behaviour change.
var subs = new OpcUaClientDriverOptions().Subscriptions;
subs.KeepAliveCount.ShouldBe(10);
subs.LifetimeCount.ShouldBe(1000u);
subs.MaxNotificationsPerPublish.ShouldBe(0u, "0 = unlimited per OPC UA spec");
subs.Priority.ShouldBe((byte)0);
subs.MinPublishingIntervalMs.ShouldBe(50);
subs.AlarmsPriority.ShouldBe((byte)1, "alarms get a higher priority than data tags so they aren't starved during bursts");
}
[Fact]
public void Subscription_defaults_are_overridable_via_options()
{
// Operators tuning a flaky-network deployment should be able to bump LifetimeCount /
// lower MaxNotificationsPerPublish without recompiling the driver. Verify the record
// is overridable end-to-end.
var opts = new OpcUaClientDriverOptions
{
Subscriptions = new OpcUaSubscriptionDefaults(
KeepAliveCount: 25,
LifetimeCount: 5000u,
MaxNotificationsPerPublish: 200u,
Priority: 7,
MinPublishingIntervalMs: 100,
AlarmsPriority: 9),
};
opts.Subscriptions.KeepAliveCount.ShouldBe(25);
opts.Subscriptions.LifetimeCount.ShouldBe(5000u);
opts.Subscriptions.MaxNotificationsPerPublish.ShouldBe(200u);
opts.Subscriptions.Priority.ShouldBe((byte)7);
opts.Subscriptions.MinPublishingIntervalMs.ShouldBe(100);
opts.Subscriptions.AlarmsPriority.ShouldBe((byte)9);
}
[Fact]
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
{
@@ -0,0 +1,175 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for <see cref="MonitoredTagSpec"/> -> SDK <c>MonitoredItem</c> mapping.
/// Assertion-only — no live SDK session required, so the tests run on every CI without
/// a real OPC UA server fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientMonitoredTagSpecTests
{
private static readonly NodeId SampleNodeId = new("Demo", 2);
[Fact]
public void BuildMonitoredItem_with_all_defaults_matches_legacy_hard_coded_values()
{
// Spec with every per-tag knob null should behave identically to the legacy
// string-only SubscribeAsync path: Reporting / SamplingInterval=publishInterval /
// QueueSize=1 / DiscardOldest=true / no filter.
var spec = new MonitoredTagSpec("ns=2;s=Demo");
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.SamplingInterval.ShouldBe(250);
item.QueueSize.ShouldBe(1u);
item.DiscardOldest.ShouldBeTrue();
item.MonitoringMode.ShouldBe(MonitoringMode.Reporting);
item.Filter.ShouldBeNull();
item.Handle.ShouldBe("ns=2;s=Demo",
"the tag string is routed through Handle so the Notification callback can identify the changed tag without re-parsing DisplayName");
}
[Fact]
public void BuildMonitoredItem_applies_per_tag_sampling_interval_independent_of_publish_interval()
{
// Per-tag SamplingInterval lets the server sample faster than it publishes — useful
// for events that change between publish ticks. If the spec sets it explicitly, the
// mapping uses that value, not the publish-interval default.
var spec = new MonitoredTagSpec("ns=2;s=Fast", SamplingIntervalMs: 50);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 1000);
item.SamplingInterval.ShouldBe(50);
}
[Fact]
public void BuildMonitoredItem_applies_queue_size_and_discard_oldest_overrides()
{
var spec = new MonitoredTagSpec("ns=2;s=DeepQueue", QueueSize: 100, DiscardOldest: false);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.QueueSize.ShouldBe(100u);
item.DiscardOldest.ShouldBeFalse(
"discard-oldest=false preserves earliest values — useful for audit-trail subscriptions where the first overflow sample is the most diagnostic");
}
[Theory]
[InlineData(SubscriptionMonitoringMode.Disabled, MonitoringMode.Disabled)]
[InlineData(SubscriptionMonitoringMode.Sampling, MonitoringMode.Sampling)]
[InlineData(SubscriptionMonitoringMode.Reporting, MonitoringMode.Reporting)]
public void BuildMonitoredItem_maps_each_monitoring_mode(SubscriptionMonitoringMode input, MonitoringMode expected)
{
var spec = new MonitoredTagSpec("ns=2;s=Mode", MonitoringMode: input);
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
item.MonitoringMode.ShouldBe(expected);
}
[Fact]
public void BuildMonitoredItem_with_absolute_deadband_emits_DataChangeFilter()
{
var spec = new MonitoredTagSpec(
"ns=2;s=Analog",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
DeadbandValue: 0.5));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValue);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Absolute);
filter.DeadbandValue.ShouldBe(0.5);
}
[Fact]
public void BuildMonitoredItem_with_percent_deadband_emits_percent_filter()
{
// PercentDeadband is calculated server-side as a fraction of EURange; the driver
// emits the filter unconditionally and lets the server return BadFilterNotAllowed
// if EURange isn't set on the variable. SubscribeAsync's catch-block swallows that
// status so other items in the batch still get created.
var spec = new MonitoredTagSpec(
"ns=2;s=Pct",
DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp,
Core.Abstractions.DeadbandType.Percent,
DeadbandValue: 5.0));
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValueTimestamp);
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Percent);
filter.DeadbandValue.ShouldBe(5.0);
}
[Theory]
[InlineData(Core.Abstractions.DataChangeTrigger.Status, Opc.Ua.DataChangeTrigger.Status)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValue, Opc.Ua.DataChangeTrigger.StatusValue)]
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValueTimestamp, Opc.Ua.DataChangeTrigger.StatusValueTimestamp)]
public void MapTrigger_round_trips_each_enum_value(
Core.Abstractions.DataChangeTrigger input, Opc.Ua.DataChangeTrigger expected)
=> OpcUaClientDriver.MapTrigger(input).ShouldBe(expected);
[Theory]
[InlineData(Core.Abstractions.DeadbandType.None, Opc.Ua.DeadbandType.None)]
[InlineData(Core.Abstractions.DeadbandType.Absolute, Opc.Ua.DeadbandType.Absolute)]
[InlineData(Core.Abstractions.DeadbandType.Percent, Opc.Ua.DeadbandType.Percent)]
public void MapDeadbandType_round_trips_each_enum_value(
Core.Abstractions.DeadbandType input, Opc.Ua.DeadbandType expected)
=> OpcUaClientDriver.MapDeadbandType(input).ShouldBe(expected);
[Fact]
public async Task DefaultInterfaceImplementation_routes_through_legacy_overload()
{
// ISubscribable's default interface impl of the per-tag overload delegates to the
// simple-string overload, ignoring per-tag knobs. Drivers that DON'T override the
// new overload (Modbus / S7 / Galaxy / TwinCAT / FOCAS / AbCip / AbLegacy) still
// accept MonitoredTagSpec lists and just pass through the tag names — back-compat
// for ISubscribable consumers.
var stub = new StubSubscribableDriver();
var specs = new[]
{
new MonitoredTagSpec("Tag1", SamplingIntervalMs: 50, QueueSize: 5),
new MonitoredTagSpec("Tag2", DataChangeFilter: new DataChangeFilterSpec(
Core.Abstractions.DataChangeTrigger.StatusValue,
Core.Abstractions.DeadbandType.Absolute,
1.0)),
};
ISubscribable iface = stub;
_ = await iface.SubscribeAsync(specs, TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
stub.LastTagNames.ShouldBe(["Tag1", "Tag2"]);
stub.LastPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
}
/// <summary>
/// Test-double <see cref="ISubscribable"/> that records whatever the legacy
/// <c>SubscribeAsync(IReadOnlyList&lt;string&gt;, ...)</c> overload was called with.
/// Used to verify the default-impl per-tag overload routes correctly without needing
/// a real OPC UA session.
/// </summary>
private sealed class StubSubscribableDriver : ISubscribable
{
public IReadOnlyList<string>? LastTagNames { get; private set; }
public TimeSpan LastPublishingInterval { get; private set; }
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
LastTagNames = fullReferences;
LastPublishingInterval = publishingInterval;
return Task.FromResult<ISubscriptionHandle>(new StubHandle());
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
#pragma warning disable CS0067 // event never used — the test only asserts the SubscribeAsync call routing
public event EventHandler<DataChangeEventArgs>? OnDataChange;
#pragma warning restore CS0067
}
private sealed record StubHandle() : ISubscriptionHandle
{
public string DiagnosticId => "stub";
}
}
@@ -0,0 +1,139 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the OperationLimits chunking surface (PR #275 / opcuaclient-3). Focused
/// on the static <see cref="OpcUaClientDriver.ChunkBy{T}"/> helper + the
/// <see cref="OpcUaClientDriver.OperationLimitsCache"/> sentinel semantics. Live
/// end-to-end tests against an in-process server land in the integration suite.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientOperationLimitsTests
{
[Fact]
public void ChunkBy_with_cap_5_splits_12_items_into_3_slices_of_5_5_2()
{
// The PR-3 acceptance scenario: server advertises MaxNodesPerRead=5, client batches a
// 12-tag read; driver must issue exactly 3 wire calls of sizes 5/5/2 in order.
var input = Enumerable.Range(0, 12).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(3);
slices[0].Count.ShouldBe(5);
slices[1].Count.ShouldBe(5);
slices[2].Count.ShouldBe(2);
// Order + offsets must reflect the original sequence — chunking must not reorder
// tags, otherwise the indexMap ↔ result-index alignment breaks.
slices[0].ShouldBe(new[] { 0, 1, 2, 3, 4 });
slices[1].ShouldBe(new[] { 5, 6, 7, 8, 9 });
slices[2].ShouldBe(new[] { 10, 11 });
}
[Fact]
public void ChunkBy_with_null_cap_yields_single_slice_no_chunking()
{
// cap=null is the "fetch hasn't completed" / "server reports 0 = no limit" sentinel.
// Both must collapse to a single SDK call so the wire path doesn't change when the
// server doesn't impose a cap.
var input = Enumerable.Range(0, 12).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: null).ToArray();
slices.Length.ShouldBe(1, "null cap means no chunking — single SDK call");
slices[0].Count.ShouldBe(12);
}
[Fact]
public void ChunkBy_with_zero_cap_yields_single_slice_no_chunking()
{
// OPC UA Part 5: 0 is the wire-level "no limit" sentinel. NormalizeLimit folds it
// into null upstream of ChunkBy, but the chunker itself must also treat 0 as
// no-chunking — defence in depth in case a caller bypasses NormalizeLimit.
var input = Enumerable.Range(0, 7).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 0).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(7);
}
[Fact]
public void ChunkBy_with_cap_larger_than_input_yields_single_slice()
{
var input = new[] { 1, 2, 3 };
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 100).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(3);
}
[Fact]
public void ChunkBy_with_empty_input_yields_no_slices()
{
// Empty batch must short-circuit before the wire call — saves a round-trip and
// matches the !toSend.Count == 0 guard in the driver.
var input = Array.Empty<int>();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(0);
}
[Fact]
public void ChunkBy_with_cap_equal_to_input_size_yields_single_slice()
{
// Edge case: exactly N items at cap N. Must NOT produce an extra empty slice.
var input = Enumerable.Range(0, 5).ToArray();
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
slices.Length.ShouldBe(1);
slices[0].Count.ShouldBe(5);
}
[Fact]
public void ChunkBy_with_cap_1_splits_each_item_into_its_own_slice()
{
// Pathological cap — degrades to N wire calls. Verifies the chunker handles the
// boundary cleanly without off-by-one.
var input = new[] { 10, 20, 30 };
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 1).ToArray();
slices.Length.ShouldBe(3);
slices[0].ShouldBe(new[] { 10 });
slices[1].ShouldBe(new[] { 20 });
slices[2].ShouldBe(new[] { 30 });
}
[Fact]
public void OperationLimitsCache_records_all_four_caps_as_nullable_uint()
{
// The cache surfaces the four limits the driver chunks against. Storing as uint?
// lets the chunker distinguish "not yet fetched" / "no limit" (null) from "limit=N".
var cache = new OpcUaClientDriver.OperationLimitsCache(
MaxNodesPerRead: 100u,
MaxNodesPerWrite: 50u,
MaxNodesPerBrowse: null,
MaxNodesPerHistoryReadData: 10u);
cache.MaxNodesPerRead.ShouldBe(100u);
cache.MaxNodesPerWrite.ShouldBe(50u);
cache.MaxNodesPerBrowse.ShouldBeNull();
cache.MaxNodesPerHistoryReadData.ShouldBe(10u);
}
[Fact]
public void Driver_starts_with_no_cached_OperationLimits()
{
// Pre-init / pre-first-batch state: cache is null so callers fall through to
// single-call behaviour. Lazy fetch happens on the first ReadAsync/WriteAsync.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cache-init");
drv.OperationLimitsForTest.ShouldBeNull();
}
}