Compare commits
48 Commits
abcip-pr1-
...
admin-host
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06cc01a48 | ||
| 5536e96b46 | |||
|
|
ece530d133 | ||
| b55cef5f8b | |||
|
|
088c4817fe | ||
| 91e6153b5d | |||
|
|
00a428c444 | ||
| 07fd105ffc | |||
|
|
8c309aebf3 | ||
| d1ca0817e9 | |||
|
|
c95228391d | ||
| 9ca80fd450 | |||
|
|
1d6015bc87 | ||
| 5cfb0fc6d0 | |||
|
|
a2c7fda5f5 | ||
| c13fe8f587 | |||
|
|
285799a954 | ||
| 9da578d5a5 | |||
|
|
6c5b202910 | ||
| a0112ddb43 | |||
|
|
aeb28cc8e7 | ||
| 2d5aaf1eda | |||
|
|
28e3470300 | ||
| bffac4db65 | |||
|
|
cd2c0bcadd | ||
| 7fdf4e5618 | |||
|
|
400fc6242c | ||
| 4438fdd7b1 | |||
|
|
b2424a0616 | ||
| 59c99190c6 | |||
|
|
fc575e8dae | ||
| 70f5f2cad1 | |||
|
|
60b8d6f2d0 | ||
| 30f971599e | |||
|
|
ac14ba9664 | ||
| 5978ea002d | |||
|
|
33780eb64c | ||
| 521bcb2f68 | |||
|
|
b06a1ba607 | ||
| dd1389a8e7 | |||
|
|
447086892e | ||
| cee52a9134 | |||
|
|
257f4fd3f5 | ||
| be2379107d | |||
|
|
cc35c77d64 | ||
| 59b59b8ccd | |||
|
|
3e0452e8a4 | ||
| bff6651b4b |
@@ -10,6 +10,10 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
@@ -29,6 +33,11 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
|
||||
@@ -56,6 +56,16 @@ else
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_rows.Any(HostStatusService.IsFlagged))
|
||||
{
|
||||
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
may trip soon. Inspect the resilience columns below to locate.
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
@@ -66,6 +76,9 @@ else
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
@@ -84,10 +97,21 @@ else
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
@if (HostStatusService.IsFlagged(r))
|
||||
{
|
||||
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="text-end small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
|
||||
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
|
||||
/// <c>/hosts</c> page renders the resilience surface inline with host state.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
@@ -18,7 +19,11 @@ public sealed record HostStatusRow(
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
@@ -36,15 +41,26 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
|
||||
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
|
||||
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
|
||||
public const int FailureFlagThreshold = 3;
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
// Two LEFT JOINs:
|
||||
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
|
||||
// hasn't been created yet (first-boot bootstrap case).
|
||||
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
|
||||
// counters haven't been sampled yet for brand-new hosts, so a missing row means
|
||||
// zero failures + never-opened breaker.
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
|
||||
from r in resilJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
@@ -54,10 +70,21 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.CurrentBulkheadDepth : 0,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
|
||||
/// failures that an operator should take notice before the breaker trips.
|
||||
/// </summary>
|
||||
public static bool IsFlagged(HostStatusRow row) =>
|
||||
row.ConsecutiveFailures >= FailureFlagThreshold;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,21 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
{
|
||||
private readonly ConcurrentDictionary<PipelineKey, ResiliencePipeline> _pipelines = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DriverResilienceStatusTracker? _statusTracker;
|
||||
|
||||
/// <summary>Construct with the ambient clock (use <see cref="TimeProvider.System"/> in prod).</summary>
|
||||
public DriverResiliencePipelineBuilder(TimeProvider? timeProvider = null)
|
||||
/// <param name="timeProvider">Clock source for pipeline timeouts + breaker sampling. Defaults to system.</param>
|
||||
/// <param name="statusTracker">When non-null, every built pipeline wires Polly telemetry into
|
||||
/// the tracker — retries increment <c>ConsecutiveFailures</c>, breaker-open stamps
|
||||
/// <c>LastBreakerOpenUtc</c>, breaker-close resets failures. Feeds Admin <c>/hosts</c> +
|
||||
/// the Polly bulkhead-depth column. Absent tracker means no telemetry (unit tests +
|
||||
/// deployments that don't care about resilience observability).</param>
|
||||
public DriverResiliencePipelineBuilder(
|
||||
TimeProvider? timeProvider = null,
|
||||
DriverResilienceStatusTracker? statusTracker = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_statusTracker = statusTracker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,8 +64,9 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
|
||||
|
||||
var key = new PipelineKey(driverInstanceId, hostName, capability);
|
||||
return _pipelines.GetOrAdd(key, static (_, state) => Build(state.capability, state.options, state.timeProvider),
|
||||
(capability, options, timeProvider: _timeProvider));
|
||||
return _pipelines.GetOrAdd(key, static (k, state) => Build(
|
||||
k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker),
|
||||
(capability, options, timeProvider: _timeProvider, tracker: _statusTracker));
|
||||
}
|
||||
|
||||
/// <summary>Drop cached pipelines for one driver instance (e.g. on ResilienceConfig change). Test + Admin-reload use.</summary>
|
||||
@@ -74,9 +85,12 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
public int CachedPipelineCount => _pipelines.Count;
|
||||
|
||||
private static ResiliencePipeline Build(
|
||||
string driverInstanceId,
|
||||
string hostName,
|
||||
DriverCapability capability,
|
||||
DriverResilienceOptions options,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
DriverResilienceStatusTracker? tracker)
|
||||
{
|
||||
var policy = options.Resolve(capability);
|
||||
var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider };
|
||||
@@ -88,7 +102,7 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
|
||||
if (policy.RetryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
var retryOptions = new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = policy.RetryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
@@ -96,19 +110,44 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(5),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
retryOptions.OnRetry = args =>
|
||||
{
|
||||
tracker.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddRetry(retryOptions);
|
||||
}
|
||||
|
||||
if (policy.BreakerFailureThreshold > 0)
|
||||
{
|
||||
builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
|
||||
var breakerOptions = new CircuitBreakerStrategyOptions
|
||||
{
|
||||
FailureRatio = 1.0,
|
||||
MinimumThroughput = policy.BreakerFailureThreshold,
|
||||
SamplingDuration = TimeSpan.FromSeconds(30),
|
||||
BreakDuration = TimeSpan.FromSeconds(15),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
breakerOptions.OnOpened = args =>
|
||||
{
|
||||
tracker.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
breakerOptions.OnClosed = args =>
|
||||
{
|
||||
// Closing the breaker means the target recovered — reset the consecutive-
|
||||
// failure counter so Admin UI stops flashing red for this host.
|
||||
tracker.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddCircuitBreaker(breakerOptions);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
|
||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Logix atomic + string data types, plus a <see cref="Structure"/> marker used when a tag
|
||||
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
|
||||
/// shape is resolved via the CIP Template Object at discovery time (PR 5 / PR 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /
|
||||
/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
|
||||
/// <see cref="Bool"/> with the <c>.N</c> bit-index carried on the <see cref="AbCipTagPath"/>
|
||||
/// rather than the data type itself.
|
||||
/// </remarks>
|
||||
public enum AbCipDataType
|
||||
{
|
||||
Bool,
|
||||
SInt, // signed 8-bit
|
||||
Int, // signed 16-bit
|
||||
DInt, // signed 32-bit
|
||||
LInt, // signed 64-bit
|
||||
USInt, // unsigned 8-bit (Logix 5000 post-V21)
|
||||
UInt, // unsigned 16-bit
|
||||
UDInt, // unsigned 32-bit
|
||||
ULInt, // unsigned 64-bit
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
|
||||
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
|
||||
/// <summary>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// resolved at discovery time; reads + writes fan out to member Variables unless the
|
||||
/// caller has explicitly opted into whole-UDT decode.
|
||||
/// </summary>
|
||||
Structure,
|
||||
}
|
||||
|
||||
/// <summary>Map a Logix atomic type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbCipDataTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Map to the driver-agnostic type the server's address-space builder consumes. Unsigned
|
||||
/// Logix types widen into signed equivalents until <c>DriverDataType</c> picks up unsigned
|
||||
/// + 64-bit variants (Modbus has the same gap — see <c>ModbusDriver.MapDataType</c>
|
||||
/// comment re: PR 25).
|
||||
/// </summary>
|
||||
public static DriverDataType ToDriverDataType(this AbCipDataType t) => t switch
|
||||
{
|
||||
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.Real => DriverDataType.Float32,
|
||||
AbCipDataType.LReal => DriverDataType.Float64,
|
||||
AbCipDataType.String => DriverDataType.String,
|
||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
729
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
729
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
@@ -0,0 +1,729 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
|
||||
/// GuardLogix families. Implements <see cref="IDriver"/> only for now — read/write/
|
||||
/// subscribe/discover capabilities ship in subsequent PRs (3–8) and family-specific quirk
|
||||
/// profiles ship in PRs 9–12.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
|
||||
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
|
||||
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
|
||||
/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.</para>
|
||||
///
|
||||
/// <para>Tier A per plan decisions #143–145 — in-process, shares server lifetime, no
|
||||
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
|
||||
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
||||
private readonly AbCipTemplateCache _templateCache = new();
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
IAbCipTemplateReaderFactory? templateReaderFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
|
||||
/// the Template Object off the controller; subsequent calls for the same
|
||||
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
|
||||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||||
/// callers can fall back to declaration-driven UDT fan-out.
|
||||
/// </summary>
|
||||
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
|
||||
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
|
||||
if (cached is not null) return cached;
|
||||
|
||||
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
|
||||
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: $"@udt/{templateInstanceId}",
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = _templateReaderFactory.Create();
|
||||
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
||||
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
||||
if (shape is not null)
|
||||
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
|
||||
return shape;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// Template read failure — log via the driver's health surface so operators see it,
|
||||
// but don't propagate since callers should fall back to declaration-driven UDT
|
||||
// semantics rather than failing the whole discovery run.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags)
|
||||
{
|
||||
_tagsByName[tag.Name] = tag;
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
foreach (var member in tag.Members)
|
||||
{
|
||||
var memberTag = new AbCipTagDefinition(
|
||||
Name: $"{tag.Name}.{member.Name}",
|
||||
DeviceHostAddress: tag.DeviceHostAddress,
|
||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
var probeParams = new AbCipTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeTagPath!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbCipTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||
// Lazy-init on first attempt; re-init after a transport failure has caused the
|
||||
// native handle to be destroyed.
|
||||
if (!state.ProbeInitialized)
|
||||
{
|
||||
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
state.ProbeInitialized = true;
|
||||
}
|
||||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
success = probeRuntime.GetStatus() == 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the device host address for a given tag full-reference. Per plan decision #144
|
||||
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
|
||||
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
|
||||
/// one dead PLC trips only its own breaker. Unknown references fall back to the
|
||||
/// first configured device's host address rather than throwing — the invoker handles the
|
||||
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
|
||||
/// </summary>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- 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>.
|
||||
/// </summary>
|
||||
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 w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInDIntAsync(
|
||||
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
|
||||
{
|
||||
var parentPath = bitPath with { BitIndex = null };
|
||||
var parentName = parentPath.ToLibplctagName();
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
? current | (1 << bit)
|
||||
: current & ~(1 << bit);
|
||||
|
||||
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||||
/// so repeated bit writes against the same DINT share one handle.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
|
||||
DeviceState device, string parentTagName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentTagName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentTagName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||
/// lifetime of the device.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
||||
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
|
||||
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
|
||||
/// </summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_templateCache.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Stream the driver's tag set into the builder. Pre-declared tags from
|
||||
/// <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
|
||||
/// <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
|
||||
/// controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
|
||||
/// routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
|
||||
/// </summary>
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("AbCip", "AbCip");
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var deviceLabel = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, deviceLabel);
|
||||
|
||||
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
|
||||
// Members fan out into a sub-folder + one Variable per member instead of a single
|
||||
// Structure Variable (Structure has no useful scalar value + member-addressable paths
|
||||
// are what downstream consumers actually want).
|
||||
var preDeclared = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in preDeclared)
|
||||
{
|
||||
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
|
||||
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||||
foreach (var member in tag.Members)
|
||||
{
|
||||
var memberFullName = $"{tag.Name}.{member.Name}";
|
||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
FullName: memberFullName,
|
||||
DriverDataType: member.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: member.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: member.WriteIdempotent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||||
}
|
||||
|
||||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||
// declared tags should appear.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
using var enumerator = _enumeratorFactory.Create();
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: "@tags",
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (discovered.IsSystemTag) continue;
|
||||
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
var fullName = discovered.ProgramScope is null
|
||||
? discovered.Name
|
||||
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
|
||||
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: discovered.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: discovered.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: (tag.Writable && !tag.SafetyTag)
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent);
|
||||
|
||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
|
||||
/// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device runtime state. Holds the parsed host address, family profile, and the
|
||||
/// live <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 3–8 populate + consume
|
||||
/// this dict via libplctag.
|
||||
/// </summary>
|
||||
internal sealed class DeviceState(
|
||||
AbCipHostAddress parsedAddress,
|
||||
AbCipDeviceOptions options,
|
||||
AbCipPlcFamilyProfile profile)
|
||||
{
|
||||
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public AbCipDeviceOptions Options { get; } = options;
|
||||
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
|
||||
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
|
||||
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
||||
/// parent ("Motor.Flags") used to do the read + write.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentTagName) =>
|
||||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var h in TagHandles.Values) h.Dispose();
|
||||
TagHandles.Clear();
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal file
125
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
|
||||
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
|
||||
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
|
||||
/// via <c>IPerCallHostResolver</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
|
||||
/// host resolver + resilience keys), #144 (bulkhead keyed on <c>(DriverInstanceId, HostName)</c>).
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
|
||||
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag call timeout applied to reads/writes/discovery when the caller does
|
||||
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
|
||||
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
|
||||
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
|
||||
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
|
||||
/// should appear in the address space.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One PLC endpoint. <see cref="HostAddress"/> must parse via
|
||||
/// <see cref="AbCipHostAddress.TryParse"/>; misconfigured devices fail driver
|
||||
/// initialization rather than silently connecting to nothing.
|
||||
/// </summary>
|
||||
/// <param name="HostAddress">Canonical <c>ab://gateway[:port]/cip-path</c> string.</param>
|
||||
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
||||
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
||||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||||
public sealed record AbCipDeviceOptions(
|
||||
string HostAddress,
|
||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
|
||||
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
|
||||
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
|
||||
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
|
||||
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
|
||||
/// <param name="WriteIdempotent">Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default <c>false</c>.</param>
|
||||
/// <param name="Members">For <see cref="AbCipDataType.Structure"/>-typed tags, the declared UDT
|
||||
/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per
|
||||
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
||||
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
|
||||
/// members individually via dotted <see cref="AbCipTagPath"/> syntax. Ignored for atomic types.</param>
|
||||
/// <param name="SafetyTag">GuardLogix safety-partition tag hint. When <c>true</c>, the driver
|
||||
/// forces <c>SecurityClassification.ViewOnly</c> on discovery regardless of
|
||||
/// <paramref name="Writable"/> — safety tags can only be written from the safety task of a
|
||||
/// 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>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string TagPath,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
/// <c>Status</c>), DataType is the atomic Logix type, Writable/WriteIdempotent mirror
|
||||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||
/// </summary>
|
||||
public sealed record AbCipStructureMember(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||
public enum AbCipPlcFamily
|
||||
{
|
||||
ControlLogix,
|
||||
CompactLogix,
|
||||
Micro800,
|
||||
GuardLogix,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
|
||||
/// on the PLC at the configured interval to drive <see cref="Core.Abstractions.IHostConnectivityProbe"/>
|
||||
/// state transitions + Admin UI health status.
|
||||
/// </summary>
|
||||
public sealed class AbCipProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Tag path used for the probe. If null, the driver attempts to read a default
|
||||
/// system tag (PR 8 wires this up — the choice is family-dependent, e.g.
|
||||
/// <c>@raw_cpu_type</c> on ControlLogix or a user-configured probe tag on Micro800).
|
||||
/// </summary>
|
||||
public string? ProbeTagPath { get; init; }
|
||||
}
|
||||
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal file
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string used by the AbCip driver
|
||||
/// as the <c>hostName</c> key across <see cref="Core.Abstractions.IHostConnectivityProbe"/>,
|
||||
/// <see cref="Core.Abstractions.IPerCallHostResolver"/>, and the Polly bulkhead key
|
||||
/// <c>(DriverInstanceId, hostName)</c> per v2 plan decision #144.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Format matches what libplctag's <c>gateway=...</c> + <c>path=...</c> attributes
|
||||
/// consume, so no translation is needed at the wire layer — the parsed <see cref="CipPath"/>
|
||||
/// is handed to the native library verbatim.</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ab://10.0.0.5/1,0</c> — single-chassis ControlLogix, CPU in slot 0.</item>
|
||||
/// <item><c>ab://10.0.0.5/1,4</c> — CPU in slot 4.</item>
|
||||
/// <item><c>ab://10.0.0.5/1,2,2,192.168.50.20,1,0</c> — bridged ControlLogix.</item>
|
||||
/// <item><c>ab://10.0.0.5/</c> (empty path) — Micro800 / MicroLogix without backplane routing.</item>
|
||||
/// <item><c>ab://10.0.0.5:44818/1,0</c> — explicit EIP port (default 44818).</item>
|
||||
/// </list>
|
||||
/// <para>Opaque to the rest of the stack: Admin UI, telemetry, and logs display the full
|
||||
/// string so an incident ticket can be matched to the exact gateway + CIP route.</para>
|
||||
/// </remarks>
|
||||
public sealed record AbCipHostAddress(string Gateway, int Port, string CipPath)
|
||||
{
|
||||
/// <summary>Default EtherNet/IP TCP port — spec-reserved.</summary>
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
/// <summary>Recompose the canonical <c>ab://...</c> form.</summary>
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="value"/>. Returns <c>null</c> on any malformed input — callers
|
||||
/// should treat a null return as a config-validation failure rather than catching.
|
||||
/// </summary>
|
||||
public static AbCipHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "ab://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var remainder = value[prefix.Length..];
|
||||
var slashIdx = remainder.IndexOf('/');
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
var authority = remainder[..slashIdx];
|
||||
var cipPath = remainder[(slashIdx + 1)..];
|
||||
if (string.IsNullOrEmpty(authority)) return null;
|
||||
|
||||
var port = DefaultEipPort;
|
||||
var colonIdx = authority.LastIndexOf(':');
|
||||
string gateway;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
gateway = authority[..colonIdx];
|
||||
if (!int.TryParse(authority[(colonIdx + 1)..], out port) || port <= 0 || port > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
gateway = authority;
|
||||
}
|
||||
if (string.IsNullOrEmpty(gateway)) return null;
|
||||
|
||||
return new AbCipHostAddress(gateway, port, cipPath);
|
||||
}
|
||||
}
|
||||
79
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal file
79
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
|
||||
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
|
||||
/// uniform across drivers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
|
||||
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
|
||||
/// ones that move the driver's status needle:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
|
||||
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
|
||||
/// (tag doesn't exist).</item>
|
||||
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
|
||||
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
|
||||
/// partition tag from a non-safety task).</item>
|
||||
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
|
||||
/// (type mismatch or truncated buffer).</item>
|
||||
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
|
||||
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
|
||||
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
|
||||
/// writes during download / test-mode transitions).</item>
|
||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||
/// <item>any libplctag <c>PLCTAG_STATUS_*</c> below zero — wrapped as
|
||||
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x04 or 0x05 => BadNodeIdUnknown,
|
||||
0x06 => GoodMoreData,
|
||||
0x08 => BadNotSupported,
|
||||
0x0A or 0x13 => BadOutOfRange,
|
||||
0x0B => Good,
|
||||
0x0E => BadNotWritable,
|
||||
0x10 => BadDeviceFailure,
|
||||
0x16 => BadNodeIdUnknown,
|
||||
_ => BadInternalError,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
|
||||
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
|
||||
/// values for errors.
|
||||
/// </summary>
|
||||
public static uint MapLibplctagStatus(int status)
|
||||
{
|
||||
if (status == 0) return Good;
|
||||
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
|
||||
return status switch
|
||||
{
|
||||
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
|
||||
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
|
||||
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
|
||||
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
|
||||
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Filters system / infrastructure tags out of discovered tag sets. A Logix controller's
|
||||
/// symbol table exposes user tags alongside module-config objects, routine pointers, task
|
||||
/// pointers, and <c>__DEFVAL_*</c> stubs that are noise for the OPC UA address space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lifted from the filter conventions documented across Rockwell Knowledgebase article
|
||||
/// IC-12345 and the Logix 5000 Controllers General Instructions Reference. The list is
|
||||
/// conservative — when in doubt, a tag is surfaced rather than hidden so operators can
|
||||
/// see it and the config flow can explicitly hide it via UnsArea ACL.
|
||||
/// </remarks>
|
||||
public static class AbCipSystemTagFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the tag name matches a well-known system-tag pattern the driver
|
||||
/// should hide from the default address space. Case-sensitive — Logix symbols are
|
||||
/// always preserved case and the system-tag prefixes are uppercase by convention.
|
||||
/// </summary>
|
||||
public static bool IsSystemTag(string tagName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName)) return true;
|
||||
|
||||
// Internal backing store for tag defaults — never user-meaningful.
|
||||
if (tagName.StartsWith("__DEFVAL_", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("__DEFAULT_", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Routine and Task pointer pseudo-tags.
|
||||
if (tagName.StartsWith("Routine:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("Task:", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Logix module-config auto-generated names — Local:1:I, Local:1:O, etc. Module data is
|
||||
// exposed separately via the dedicated hardware mapping; the auto-generated symbol-table
|
||||
// entries duplicate that.
|
||||
if (tagName.StartsWith("Local:", StringComparison.Ordinal) && tagName.Contains(':')) return true;
|
||||
|
||||
// Map / Mapped IO alias tags (MainProgram.MapName pattern — dot-separated but prefixed
|
||||
// with a reserved colon-carrying prefix to avoid false positives on user member access).
|
||||
if (tagName.StartsWith("Map:", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Axis / Cam / Motion-Group predefined structures — exposed separately through motion API.
|
||||
if (tagName.StartsWith("Axis:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("Cam:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("MotionGroup:", StringComparison.Ordinal)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
132
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
Normal file
132
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed Logix-symbolic tag path. Handles controller-scope (<c>Motor1_Speed</c>),
|
||||
/// program-scope (<c>Program:MainProgram.StepIndex</c>), structured member access
|
||||
/// (<c>Motor1.Speed.Setpoint</c>), array subscripts (<c>Array[0]</c>, <c>Matrix[1,2]</c>),
|
||||
/// and bit-within-DINT access (<c>Flags.3</c>). Reassembles the canonical Logix syntax via
|
||||
/// <see cref="ToLibplctagName"/>, which is the exact string libplctag's <c>name=...</c>
|
||||
/// attribute consumes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope + members + subscripts are captured structurally so PR 6 (UDT support) can walk
|
||||
/// the path against a cached template without re-parsing. <see cref="BitIndex"/> is
|
||||
/// non-null only when the trailing segment is a decimal integer between 0 and 31 that
|
||||
/// parses as a bit-selector — this is the <c>.N</c> syntax documented in the Logix 5000
|
||||
/// General Instructions Reference §Tags, and it applies only to DINT-typed parents. The
|
||||
/// parser does not validate the parent type (requires live template data) — it accepts the
|
||||
/// shape and defers type-correctness to the runtime.
|
||||
/// </remarks>
|
||||
public sealed record AbCipTagPath(
|
||||
string? ProgramScope,
|
||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||
int? BitIndex)
|
||||
{
|
||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||
public string 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(']');
|
||||
}
|
||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||
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
|
||||
/// attempting a best-effort translation.
|
||||
/// </summary>
|
||||
public static AbCipTagPath? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
string? programScope = null;
|
||||
const string programPrefix = "Program:";
|
||||
if (src.StartsWith(programPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var afterPrefix = src[programPrefix.Length..];
|
||||
var dotIdx = afterPrefix.IndexOf('.');
|
||||
if (dotIdx <= 0) return null;
|
||||
programScope = afterPrefix[..dotIdx];
|
||||
src = afterPrefix[(dotIdx + 1)..];
|
||||
if (string.IsNullOrEmpty(src)) return null;
|
||||
}
|
||||
|
||||
// Split on dots, but preserve any [i,j] subscript runs that contain only digits + commas.
|
||||
var parts = new List<string>();
|
||||
var depth = 0;
|
||||
var start = 0;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
var c = src[i];
|
||||
if (c == '[') depth++;
|
||||
else if (c == ']') depth--;
|
||||
else if (c == '.' && depth == 0)
|
||||
{
|
||||
parts.Add(src[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.Add(src[start..]);
|
||||
if (depth != 0 || parts.Any(string.IsNullOrEmpty)) return null;
|
||||
|
||||
int? bitIndex = null;
|
||||
if (parts.Count >= 2 && int.TryParse(parts[^1], out var maybeBit)
|
||||
&& maybeBit is >= 0 and <= 31
|
||||
&& !parts[^1].Contains('['))
|
||||
{
|
||||
bitIndex = maybeBit;
|
||||
parts.RemoveAt(parts.Count - 1);
|
||||
}
|
||||
|
||||
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
if (!IsValidIdent(part)) return null;
|
||||
segments.Add(new AbCipTagPathSegment(part, []));
|
||||
continue;
|
||||
}
|
||||
if (!part.EndsWith(']')) return null;
|
||||
var name = part[..bracketIdx];
|
||||
if (!IsValidIdent(name)) return null;
|
||||
var inner = part[(bracketIdx + 1)..^1];
|
||||
var subs = new List<int>();
|
||||
foreach (var tok in inner.Split(','))
|
||||
{
|
||||
if (!int.TryParse(tok, out var n) || n < 0) return null;
|
||||
subs.Add(n);
|
||||
}
|
||||
if (subs.Count == 0) return null;
|
||||
segments.Add(new AbCipTagPathSegment(name, subs));
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
|
||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
||||
}
|
||||
|
||||
private static bool IsValidIdent(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return false;
|
||||
if (!char.IsLetter(s[0]) && s[0] != '_') return false;
|
||||
for (var i = 1; i < s.Length; i++)
|
||||
if (!char.IsLetterOrDigit(s[i]) && s[i] != '_') return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||
55
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Cache of UDT shape descriptors keyed by <c>(deviceHostAddress, templateInstanceId)</c>.
|
||||
/// Populated on demand during discovery + whole-UDT reads; flushed via
|
||||
/// <see cref="AbCipDriver.FlushOptionalCachesAsync"/> and on device
|
||||
/// <c>ReinitializeAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Template shape read (CIP Template Object class 0x6C, <c>GetAttributeList</c> +
|
||||
/// <c>Read Template</c>) lands with PR 6. This class ships the cache surface so PR 6 can
|
||||
/// drop the decoder in without reshaping any caller code.
|
||||
/// </remarks>
|
||||
public sealed class AbCipTemplateCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string device, uint instanceId), AbCipUdtShape> _shapes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a cached UDT shape, or <c>null</c> if not yet read.
|
||||
/// </summary>
|
||||
public AbCipUdtShape? TryGet(string deviceHostAddress, uint templateInstanceId) =>
|
||||
_shapes.TryGetValue((deviceHostAddress, templateInstanceId), out var shape) ? shape : null;
|
||||
|
||||
/// <summary>Store a freshly-decoded UDT shape.</summary>
|
||||
public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) =>
|
||||
_shapes[(deviceHostAddress, templateInstanceId)] = shape;
|
||||
|
||||
/// <summary>Drop every cached shape — called on <see cref="AbCipDriver.FlushOptionalCachesAsync"/>.</summary>
|
||||
public void Clear() => _shapes.Clear();
|
||||
|
||||
/// <summary>Count of cached shapes — exposed for diagnostics + tests.</summary>
|
||||
public int Count => _shapes.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoded shape of one Logix UDT — member list + each member's offset + type. Populated
|
||||
/// by PR 6's Template Object reader. At PR 5 time this is the cache's value type only;
|
||||
/// no reader writes to it yet.
|
||||
/// </summary>
|
||||
/// <param name="TypeName">UDT name as reported by the Template Object.</param>
|
||||
/// <param name="TotalSize">Bytes the UDT occupies in a whole-UDT read buffer.</param>
|
||||
/// <param name="Members">Ordered list of members, each with its byte offset + type.</param>
|
||||
public sealed record AbCipUdtShape(
|
||||
string TypeName,
|
||||
int TotalSize,
|
||||
IReadOnlyList<AbCipUdtMember> Members);
|
||||
|
||||
/// <summary>One member of a Logix UDT.</summary>
|
||||
public sealed record AbCipUdtMember(
|
||||
string Name,
|
||||
int Offset,
|
||||
AbCipDataType DataType,
|
||||
int ArrayLength);
|
||||
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
|
||||
/// when a client reads the <c>@tags</c> pseudo-tag. Parses the concatenated tag-info
|
||||
/// entries into a sequence of <see cref="AbCipDiscoveredTag"/>s that the driver can stream
|
||||
/// into the address-space builder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
|
||||
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
|
||||
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
|
||||
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
|
||||
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
|
||||
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
|
||||
/// (not a primitive type code).</description></item>
|
||||
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
|
||||
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
|
||||
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
|
||||
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
|
||||
/// (<c>Program:MainProgram.StepIndex</c>); decoder strips the prefix + emits the scope
|
||||
/// separately so the driver's IAddressSpaceBuilder can organise them.</para>
|
||||
/// </remarks>
|
||||
public static class CipSymbolObjectDecoder
|
||||
{
|
||||
// Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
|
||||
// + array-dims(4×3) + name-length(2) = 22.
|
||||
private const int FixedHeaderSize = 22;
|
||||
|
||||
private const ushort SymbolTypeSystemFlag = 0x1000;
|
||||
private const ushort SymbolTypeStructFlag = 0x8000;
|
||||
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
|
||||
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
|
||||
/// cleanly before the corruption.
|
||||
/// </summary>
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
return DecodeImpl(buffer);
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
|
||||
{
|
||||
var pos = 0;
|
||||
while (pos + FixedHeaderSize <= buffer.Length)
|
||||
{
|
||||
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
|
||||
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
|
||||
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
|
||||
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
|
||||
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
|
||||
pos += FixedHeaderSize;
|
||||
|
||||
if (pos + nameLength > buffer.Length) break;
|
||||
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
|
||||
pos += nameLength;
|
||||
if ((pos & 1) != 0) pos++; // even-align for the next entry
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
|
||||
var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
|
||||
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
|
||||
var typeCode = symbolType & SymbolTypeTypeCodeMask;
|
||||
|
||||
var (programScope, simpleName) = SplitProgramScope(name);
|
||||
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
|
||||
|
||||
yield return new AbCipDiscoveredTag(
|
||||
Name: simpleName,
|
||||
ProgramScope: programScope,
|
||||
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
|
||||
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
|
||||
IsSystemTag: isSystem);
|
||||
|
||||
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||
/// </summary>
|
||||
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
|
||||
{
|
||||
const string prefix = "Program:";
|
||||
if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
|
||||
var afterPrefix = fullName[prefix.Length..];
|
||||
var dot = afterPrefix.IndexOf('.');
|
||||
if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
|
||||
return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
|
||||
/// <see cref="AbCipDataType"/> surface. Returns <c>null</c> for unrecognised codes —
|
||||
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
|
||||
/// surfaced + downstream config can add a concrete type override.
|
||||
/// </summary>
|
||||
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
|
||||
{
|
||||
0xC1 => AbCipDataType.Bool,
|
||||
0xC2 => AbCipDataType.SInt,
|
||||
0xC3 => AbCipDataType.Int,
|
||||
0xC4 => AbCipDataType.DInt,
|
||||
0xC5 => AbCipDataType.LInt,
|
||||
0xC6 => AbCipDataType.USInt,
|
||||
0xC7 => AbCipDataType.UInt,
|
||||
0xC8 => AbCipDataType.UDInt,
|
||||
0xC9 => AbCipDataType.ULInt,
|
||||
0xCA => AbCipDataType.Real,
|
||||
0xCB => AbCipDataType.LReal,
|
||||
0xCD => AbCipDataType.Dt, // DATE
|
||||
0xCF => AbCipDataType.Dt, // DATE_AND_TIME
|
||||
0xD0 => AbCipDataType.String,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
|
||||
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
|
||||
/// + ordered member list with per-member offset + type + array length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
|
||||
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
|
||||
/// <c>handle_read_template_reply</c>:</para>
|
||||
///
|
||||
/// <para>Header (fixed-size, little-endian):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member count.</description></item>
|
||||
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
|
||||
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
|
||||
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
|
||||
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
|
||||
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
|
||||
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then strings: UDT name followed by each member name, each terminated by a
|
||||
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
|
||||
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
|
||||
/// Decoder trims to the first semicolon.</para>
|
||||
/// </remarks>
|
||||
public static class CipTemplateObjectDecoder
|
||||
{
|
||||
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
|
||||
private const int MemberBlockSize = 8; // u16 + u16 + u32
|
||||
|
||||
private const ushort MemberInfoStructFlag = 0x8000;
|
||||
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
|
||||
/// zero members or the buffer is too short to hold the fixed header.
|
||||
/// </summary>
|
||||
public static AbCipUdtShape? Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
if (buffer.Length < HeaderSize) return null;
|
||||
|
||||
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
|
||||
// bytes 2-3: struct handle — opaque, not needed for the shape record
|
||||
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
|
||||
// bytes 8-11: member-definition total size — inferred from names list instead
|
||||
|
||||
if (memberCount == 0) return null;
|
||||
|
||||
var memberBlocksOffset = HeaderSize;
|
||||
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
|
||||
if (namesOffset > buffer.Length) return null;
|
||||
|
||||
var stringsSpan = buffer.AsSpan(namesOffset);
|
||||
var names = ParseSemicolonTerminatedStrings(stringsSpan);
|
||||
if (names.Count == 0) return null;
|
||||
|
||||
// Strings layout: UDT name first, then one per member (in the same order as the
|
||||
// member-info blocks). Always consume the first entry as the UDT name; missing
|
||||
// trailing member names get <member_N> placeholders below.
|
||||
var udtName = names[0];
|
||||
var memberNames = names.Skip(1).ToArray();
|
||||
|
||||
var members = new List<AbCipUdtMember>(memberCount);
|
||||
for (var i = 0; i < memberCount; i++)
|
||||
{
|
||||
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
|
||||
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
|
||||
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
|
||||
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
|
||||
|
||||
var isStruct = (info & MemberInfoStructFlag) != 0;
|
||||
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
|
||||
var dataType = isStruct
|
||||
? AbCipDataType.Structure
|
||||
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
|
||||
|
||||
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
|
||||
members.Add(new AbCipUdtMember(
|
||||
Name: memberName,
|
||||
Offset: offset,
|
||||
DataType: dataType,
|
||||
ArrayLength: arraySize == 0 ? 1 : arraySize));
|
||||
}
|
||||
|
||||
return new AbCipUdtShape(
|
||||
TypeName: udtName,
|
||||
TotalSize: (int)instanceSize,
|
||||
Members: members);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
|
||||
/// the null byte after each semicolon is optional padding per Rockwell's string
|
||||
/// encoding convention. Stops at a trailing null / end of buffer.
|
||||
/// </summary>
|
||||
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var start = 0;
|
||||
for (var i = 0; i < span.Length; i++)
|
||||
{
|
||||
var b = span[i];
|
||||
if (b == ';')
|
||||
{
|
||||
if (i > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..i]));
|
||||
// Skip the optional null/space padding following the semicolon.
|
||||
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
|
||||
i++;
|
||||
start = i + 1;
|
||||
}
|
||||
else if (b == 0 && start == i)
|
||||
{
|
||||
// Trailing null at a string boundary — done.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Trailing name without a semicolon (unlikely but observed on some firmwares).
|
||||
if (start < span.Length)
|
||||
{
|
||||
var zeroAt = span[start..].IndexOf((byte)0);
|
||||
var end = zeroAt < 0 ? span.Length : start + zeroAt;
|
||||
if (end > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..end]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
67
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs
Normal file
67
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Swappable scanner that walks a controller's symbol table (via libplctag's
|
||||
/// <c>@tags</c> pseudo-tag or the CIP Symbol Object class 0x6B) and yields the tags it
|
||||
/// finds. Defaults to <see cref="EmptyAbCipTagEnumeratorFactory"/> which returns no
|
||||
/// controller-side tags — the full <c>@tags</c> decoder lands as a follow-up PR once
|
||||
/// libplctag 1.5.2 either gains <c>TagInfoPlcMapper</c> upstream or we ship our own
|
||||
/// <c>IPlcMapper</c> for the Symbol Object byte layout (tracked via follow-up task; PR 5
|
||||
/// ships the abstraction + pre-declared-tag emission).
|
||||
/// </summary>
|
||||
public interface IAbCipTagEnumerator : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerate the controller's tags for one device. Callers iterate asynchronously so
|
||||
/// large symbol tables don't require buffering the entire list.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for per-driver enumerators.</summary>
|
||||
public interface IAbCipTagEnumeratorFactory
|
||||
{
|
||||
IAbCipTagEnumerator Create();
|
||||
}
|
||||
|
||||
/// <summary>One tag yielded by <see cref="IAbCipTagEnumerator.EnumerateAsync"/>.</summary>
|
||||
/// <param name="Name">Logix symbolic name as returned by the Symbol Object.</param>
|
||||
/// <param name="ProgramScope">Program name if the tag is program-scoped; <c>null</c> for controller scope.</param>
|
||||
/// <param name="DataType">Detected data type; <see cref="AbCipDataType.Structure"/> when the tag
|
||||
/// is UDT-typed — the UDT shape lookup + per-member expansion ship with PR 6.</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the Symbol Object's External Access attribute forbids writes.</param>
|
||||
/// <param name="IsSystemTag">Hint from the enumerator that this is a system / infrastructure tag;
|
||||
/// the driver applies <see cref="AbCipSystemTagFilter"/> on top so the enumerator is not the
|
||||
/// single source of truth.</param>
|
||||
public sealed record AbCipDiscoveredTag(
|
||||
string Name,
|
||||
string? ProgramScope,
|
||||
AbCipDataType DataType,
|
||||
bool ReadOnly,
|
||||
bool IsSystemTag = false);
|
||||
|
||||
/// <summary>
|
||||
/// Default production enumerator — currently returns an empty sequence. The real <c>@tags</c>
|
||||
/// walk lands as a follow-up PR. Documented in <c>driver-specs.md §3</c> as the gap the
|
||||
/// Symbol Object walker closes.
|
||||
/// </summary>
|
||||
internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="EmptyAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class EmptyAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
public IAbCipTagEnumerator Create() => new EmptyAbCipTagEnumerator();
|
||||
}
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wire-layer abstraction over a single CIP tag. The driver holds one instance per
|
||||
/// <c>(device, tag path)</c> pair; the default implementation delegates to
|
||||
/// <see cref="LibplctagTagRuntime"/>. Tests swap in a fake via
|
||||
/// <see cref="IAbCipTagFactory"/> so the driver's read / write / status-mapping logic can
|
||||
/// be exercised without a running PLC or the native libplctag binary.
|
||||
/// </summary>
|
||||
public interface IAbCipTagRuntime : IDisposable
|
||||
{
|
||||
/// <summary>Create the underlying native tag (equivalent to libplctag's <c>plc_tag_create</c>).</summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Issue a read; on completion the local buffer holds the current PLC value.</summary>
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Flush the local buffer to the PLC.</summary>
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>. Zero on success, negative on error.
|
||||
/// </summary>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
|
||||
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
|
||||
/// the <c>.N</c> syntax at parse time.
|
||||
/// </summary>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
/// </summary>
|
||||
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for per-tag runtime handles. Instantiated once per driver, consumed per
|
||||
/// <c>(device, tag path)</c> pair at the first read/write.
|
||||
/// </summary>
|
||||
public interface IAbCipTagFactory
|
||||
{
|
||||
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
|
||||
}
|
||||
|
||||
/// <summary>Everything libplctag needs to materialise a tag handle.</summary>
|
||||
/// <param name="Gateway">Gateway IP / hostname parsed from <see cref="AbCipHostAddress.Gateway"/>.</param>
|
||||
/// <param name="Port">EtherNet/IP TCP port — default 44818.</param>
|
||||
/// <param name="CipPath">CIP route path, e.g. <c>1,0</c>. Empty for Micro800.</param>
|
||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||
public sealed record AbCipTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw Template Object (class 0x6C) blob for a given UDT template instance id
|
||||
/// off a Logix controller. The default production implementation (see
|
||||
/// <see cref="LibplctagTemplateReader"/>) uses libplctag's <c>@udt/{id}</c> pseudo-tag.
|
||||
/// Tests swap in a fake via <see cref="IAbCipTemplateReaderFactory"/>.
|
||||
/// </summary>
|
||||
public interface IAbCipTemplateReader : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the raw template bytes for <paramref name="templateInstanceId"/>. Returns the
|
||||
/// full blob the Read Template service produced — the managed <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses it into an <see cref="AbCipUdtShape"/>.
|
||||
/// </summary>
|
||||
Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IAbCipTemplateReader"/>.</summary>
|
||||
public interface IAbCipTemplateReaderFactory
|
||||
{
|
||||
IAbCipTemplateReader Create();
|
||||
}
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
|
||||
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
|
||||
/// response with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
|
||||
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para>Task #178 closed the stub gap from PR 5 — <see cref="EmptyAbCipTagEnumerator"/>
|
||||
/// is still available for tests that don't want to touch the native library, but the
|
||||
/// production factory default now wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// Build a tag specifically for the @tags pseudo — same gateway + path as the device,
|
||||
// distinguished by the name alone.
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = deviceParams.Gateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = "@tags",
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var buffer = _tag.GetBuffer();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix,
|
||||
"micro800" => PlcType.Micro800,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
}
|
||||
138
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
Normal file
138
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag-backed <see cref="IAbCipTagRuntime"/>. Wraps a <see cref="Tag"/>
|
||||
/// instance + translates our <see cref="AbCipDataType"/> enum into the
|
||||
/// <c>GetInt32</c> / <c>GetFloat32</c> / <c>GetString</c> / <c>GetBit</c> calls libplctag
|
||||
/// exposes. One runtime instance per <c>(device, tag path)</c>; lifetime is owned by the
|
||||
/// driver's per-device state dict.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = p.Gateway,
|
||||
Path = p.CipPath,
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(0),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
|
||||
AbCipDataType.DInt => _tag.GetInt32(0),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
|
||||
AbCipDataType.LInt => _tag.GetInt64(0),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
|
||||
AbCipDataType.Real => _tag.GetFloat32(0),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(0),
|
||||
AbCipDataType.String => _tag.GetString(0),
|
||||
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
|
||||
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.Bool:
|
||||
if (bitIndex is int)
|
||||
{
|
||||
// BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
|
||||
// WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
|
||||
// serialised. If one reaches here it means the driver dispatch was bypassed —
|
||||
// throw so the error surfaces loudly rather than clobbering the whole DINT.
|
||||
throw new NotSupportedException(
|
||||
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not LibplctagTagRuntime.");
|
||||
}
|
||||
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||
break;
|
||||
case AbCipDataType.SInt:
|
||||
_tag.SetInt8(0, Convert.ToSByte(value));
|
||||
break;
|
||||
case AbCipDataType.USInt:
|
||||
_tag.SetUInt8(0, Convert.ToByte(value));
|
||||
break;
|
||||
case AbCipDataType.Int:
|
||||
_tag.SetInt16(0, Convert.ToInt16(value));
|
||||
break;
|
||||
case AbCipDataType.UInt:
|
||||
_tag.SetUInt16(0, Convert.ToUInt16(value));
|
||||
break;
|
||||
case AbCipDataType.DInt:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbCipDataType.UDInt:
|
||||
_tag.SetUInt32(0, Convert.ToUInt32(value));
|
||||
break;
|
||||
case AbCipDataType.LInt:
|
||||
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||
break;
|
||||
case AbCipDataType.ULInt:
|
||||
_tag.SetUInt64(0, Convert.ToUInt64(value));
|
||||
break;
|
||||
case AbCipDataType.Real:
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbCipDataType.LReal:
|
||||
_tag.SetFloat64(0, Convert.ToDouble(value));
|
||||
break;
|
||||
case AbCipDataType.String:
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbCipDataType.Dt:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||
default:
|
||||
throw new NotSupportedException($"AbCipDataType {type} not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix, // libplctag treats CompactLogix under ControlLogix family
|
||||
"micro800" => PlcType.Micro800,
|
||||
"micrologix" => PlcType.MicroLogix,
|
||||
"slc500" => PlcType.Slc500,
|
||||
"plc5" => PlcType.Plc5,
|
||||
"omron-njnx" => PlcType.Omron,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAbCipTagFactory"/> — creates a fresh <see cref="LibplctagTagRuntime"/>
|
||||
/// per call. Stateless; safe to share across devices.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTagFactory : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new LibplctagTagRuntime(createParams);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// libplctag-backed <see cref="IAbCipTemplateReader"/>. Opens the <c>@udt/{templateId}</c>
|
||||
/// pseudo-tag libplctag exposes for Template Object reads, issues a <c>Read Template</c>
|
||||
/// internally via a normal read call, + returns the raw byte buffer so
|
||||
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
public async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_tag?.Dispose();
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = deviceParams.Gateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = $"@udt/{templateInstanceId}",
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _tag.GetBuffer();
|
||||
}
|
||||
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix,
|
||||
"micro800" => PlcType.Micro800,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class LibplctagTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
public IAbCipTemplateReader Create() => new LibplctagTemplateReader();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family libplctag defaults. Picked up at device-initialization time so each PLC
|
||||
/// family gets the correct ConnectionSize, path semantics, and quirks applied without
|
||||
/// the caller having to know the protocol-level differences.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the shape of the Modbus driver's per-family profiles (DL205, Siemens S7,
|
||||
/// Mitsubishi MELSEC). ControlLogix is the baseline; each subsequent family is a delta.
|
||||
/// Family-specific wire tests ship in PRs 9–12.
|
||||
/// </remarks>
|
||||
public sealed record AbCipPlcFamilyProfile(
|
||||
string LibplctagPlcAttribute,
|
||||
int DefaultConnectionSize,
|
||||
string DefaultCipPath,
|
||||
bool SupportsRequestPacking,
|
||||
bool SupportsConnectedMessaging,
|
||||
int MaxFragmentBytes)
|
||||
{
|
||||
/// <summary>Look up the profile for a configured family.</summary>
|
||||
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
||||
{
|
||||
AbCipPlcFamily.ControlLogix => ControlLogix,
|
||||
AbCipPlcFamily.CompactLogix => CompactLogix,
|
||||
AbCipPlcFamily.Micro800 => Micro800,
|
||||
AbCipPlcFamily.GuardLogix => GuardLogix,
|
||||
_ => ControlLogix,
|
||||
};
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile ControlLogix = new(
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
DefaultConnectionSize: 4002, // Large Forward Open; FW20+
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 4000);
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
|
||||
LibplctagPlcAttribute: "compactlogix",
|
||||
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 500);
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile Micro800 = new(
|
||||
LibplctagPlcAttribute: "micro800",
|
||||
DefaultConnectionSize: 488, // Micro800 hard cap
|
||||
DefaultCipPath: "", // no backplane routing
|
||||
SupportsRequestPacking: false,
|
||||
SupportsConnectedMessaging: false, // unconnected-only on most models
|
||||
MaxFragmentBytes: 484);
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
|
||||
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
|
||||
DefaultConnectionSize: 4002,
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 4000);
|
||||
}
|
||||
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcTagHandle.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SafeHandle"/> wrapper around a libplctag native tag handle (an <c>int32</c>
|
||||
/// returned from <c>plc_tag_create_ex</c>). Owns lifetime of the native allocation so a
|
||||
/// leaked / GC-collected <see cref="PlcTagHandle"/> still calls <c>plc_tag_destroy</c>
|
||||
/// during finalization — necessary because native libplctag allocations are opaque to
|
||||
/// the driver's <see cref="Core.Abstractions.IDriver.GetMemoryFootprint"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Risk documented in driver-specs.md §3 ("Operational Stability Notes"): the CLR
|
||||
/// allocation tracker doesn't see libplctag's native heap, only whole-process RSS can.
|
||||
/// Every handle leaked past its useful life is a direct contributor to the Tier-B recycle
|
||||
/// trigger, so owning lifetime via SafeHandle is non-negotiable.</para>
|
||||
///
|
||||
/// <para><see cref="IsInvalid"/> is <c>true</c> when the native ID is <= 0 — libplctag
|
||||
/// returns negative <c>PLCTAG_ERR_*</c> codes on <c>plc_tag_create_ex</c> failure, which
|
||||
/// we surface as an invalid handle rather than a disposable one (destroying a negative
|
||||
/// handle would be undefined behavior in the native library).</para>
|
||||
///
|
||||
/// <para>The actual <c>DllImport</c> for <c>plc_tag_destroy</c> is deferred to PR 3 when
|
||||
/// the driver first makes wire calls — PR 2 ships the lifetime scaffold + tests only.
|
||||
/// Until the P/Invoke lands, <see cref="ReleaseHandle"/> is a no-op; the finalizer still
|
||||
/// runs so the integration is correct as soon as the import is added.</para>
|
||||
/// </remarks>
|
||||
public sealed class PlcTagHandle : SafeHandle
|
||||
{
|
||||
/// <summary>Construct an invalid handle placeholder (use <see cref="FromNative"/> once created).</summary>
|
||||
public PlcTagHandle() : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true) { }
|
||||
|
||||
private PlcTagHandle(int nativeId) : base(invalidHandleValue: IntPtr.Zero, ownsHandle: true)
|
||||
{
|
||||
SetHandle(new IntPtr(nativeId));
|
||||
}
|
||||
|
||||
/// <summary>Handle is invalid when the native ID is zero or negative (libplctag error).</summary>
|
||||
public override bool IsInvalid => handle.ToInt32() <= 0;
|
||||
|
||||
/// <summary>Integer ID libplctag issued on <c>plc_tag_create_ex</c>.</summary>
|
||||
public int NativeId => handle.ToInt32();
|
||||
|
||||
/// <summary>Wrap a native tag ID returned from libplctag.</summary>
|
||||
public static PlcTagHandle FromNative(int nativeId) => new(nativeId);
|
||||
|
||||
/// <summary>
|
||||
/// Destroy the native tag. No-op for PR 2 (the wire P/Invoke lands in PR 3). The base
|
||||
/// <see cref="SafeHandle"/> machinery still guarantees this runs exactly once per
|
||||
/// handle — either during <see cref="SafeHandle.Dispose()"/> or during finalization
|
||||
/// if the owner was GC'd without explicit Dispose.
|
||||
/// </summary>
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
if (IsInvalid) return true;
|
||||
// PR 3: wire up plc_tag_destroy(handle.ToInt32()) once the DllImport lands.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.AbCip</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libplctag managed wrapper (pulls in libplctag.NativeImport transitively).
|
||||
Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix /
|
||||
Micro800 / SLC500 / PLC-5. -->
|
||||
<PackageReference Include="libplctag" Version="1.5.2"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
||||
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
||||
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
||||
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
||||
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
||||
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
||||
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
||||
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
||||
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
||||
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// </list>
|
||||
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> attribute —
|
||||
/// the PLC-side decoder handles the format. This parser only validates the shape + surfaces
|
||||
/// the structural pieces for driver-side routing (e.g. deciding whether a tag needs
|
||||
/// bit-level read-modify-write).</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyAddress(
|
||||
string FileLetter,
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
{
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
if (candidate.Length > 0 && candidate.All(char.IsLetter))
|
||||
{
|
||||
subElement = candidate.ToUpperInvariant();
|
||||
src = src[..dotIdx];
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
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).
|
||||
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;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], 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;
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
}
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
{
|
||||
"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,
|
||||
};
|
||||
}
|
||||
45
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
Normal file
45
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// PCCC data types that map onto SLC / MicroLogix / PLC-5 files. Narrower than Logix — no
|
||||
/// symbolic UDTs; every type is file-typed and fixed-width.
|
||||
/// </summary>
|
||||
public enum AbLegacyDataType
|
||||
{
|
||||
/// <summary>B-file single bit (<c>B3:0/0</c>) or bit-within-N-file (<c>N7:0/3</c>).</summary>
|
||||
Bit,
|
||||
/// <summary>N-file integer (signed 16-bit).</summary>
|
||||
Int,
|
||||
/// <summary>L-file long integer — SLC 5/05+ only (signed 32-bit).</summary>
|
||||
Long,
|
||||
/// <summary>F-file float (32-bit IEEE-754).</summary>
|
||||
Float,
|
||||
/// <summary>A-file analog integer — some older hardware (signed 16-bit, semantically like N).</summary>
|
||||
AnalogInt,
|
||||
/// <summary>ST-file string (82-byte fixed-length + length word header).</summary>
|
||||
String,
|
||||
/// <summary>Timer sub-element — caller addresses <c>.ACC</c>, <c>.PRE</c>, <c>.EN</c>, <c>.DN</c>, <c>.TT</c>.</summary>
|
||||
TimerElement,
|
||||
/// <summary>Counter sub-element — caller addresses <c>.ACC</c>, <c>.PRE</c>, <c>.CU</c>, <c>.CD</c>, <c>.DN</c>.</summary>
|
||||
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>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbLegacyDataTypeExtensions
|
||||
{
|
||||
public static DriverDataType ToDriverDataType(this AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Bit => DriverDataType.Boolean,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => DriverDataType.Int32,
|
||||
AbLegacyDataType.Long => DriverDataType.Int32, // matches Modbus/AbCip 64→32 gap
|
||||
AbLegacyDataType.Float => DriverDataType.Float32,
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
481
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
481
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
Normal file
@@ -0,0 +1,481 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
|
||||
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||
IAbLegacyTagFactory? tagFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
// Probe loops — one per device when enabled + probe address configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeRuntimes();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
internal int DeviceCount => _devices.Count;
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
|
||||
// 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
|
||||
// concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits
|
||||
// (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed.
|
||||
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit
|
||||
&& parsed.FileLetter is not "B" and not "I" and not "O")
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapLibplctagStatus(status));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("AbLegacy", "AbLegacy");
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
var probeParams = new AbLegacyTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbLegacyTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||
if (!state.ProbeInitialized)
|
||||
{
|
||||
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
state.ProbeInitialized = true;
|
||||
}
|
||||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
success = probeRuntime.GetStatus() == 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch
|
||||
{
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PCCC N-file word. Strips the /N bit suffix to
|
||||
/// form the parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime
|
||||
/// typed as Int16, serialises concurrent bit writers against the same parent via a
|
||||
/// per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
|
||||
{
|
||||
var parentAddress = bitAddress with { BitIndex = null };
|
||||
var parentName = parentAddress.ToLibplctagName();
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
? current | (1 << bit)
|
||||
: current & ~(1 << bit);
|
||||
|
||||
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(
|
||||
AbLegacyHostAddress parsedAddress,
|
||||
AbLegacyDeviceOptions options,
|
||||
AbLegacyPlcFamilyProfile profile)
|
||||
{
|
||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public AbLegacyDeviceOptions Options { get; } = options;
|
||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
|
||||
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
||||
/// single parent runtime for N7:0.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentName) =>
|
||||
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
public void DisposeRuntimes()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// AB Legacy (PCCC) driver configuration. One instance supports N devices (SLC 500 /
|
||||
/// MicroLogix / PLC-5 / LogixPccc). Per plan decision #41 AbLegacy ships separately from
|
||||
/// AbCip because PCCC's file-based addressing (<c>N7:0</c>) and Logix's symbolic addressing
|
||||
/// (<c>Motor1.Speed</c>) pull the abstraction in different directions.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverOptions
|
||||
{
|
||||
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public sealed record AbLegacyDeviceOptions(
|
||||
string HostAddress,
|
||||
AbLegacyPlcFamily PlcFamily = AbLegacyPlcFamily.Slc500,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
AbLegacyDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class AbLegacyProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Probe address — defaults to <c>S:0</c> (status file, first word) when null.</summary>
|
||||
public string? ProbeAddress { get; init; } = "S:0";
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string for AB Legacy devices.
|
||||
/// Same format as AbCip — PCCC-over-EIP uses the same gateway + optional routing path as
|
||||
/// the CIP family (a PLC-5 bridged through a ControlLogix chassis takes the full CIP path;
|
||||
/// a direct-wired SLC 500 uses an empty path).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers ship
|
||||
/// independently + a shared helper would force a reference between them. If a third AB
|
||||
/// driver appears, extract into Core.Abstractions.
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPath)
|
||||
{
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
public static AbLegacyHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "ab://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var remainder = value[prefix.Length..];
|
||||
var slashIdx = remainder.IndexOf('/');
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
var authority = remainder[..slashIdx];
|
||||
var cipPath = remainder[(slashIdx + 1)..];
|
||||
if (string.IsNullOrEmpty(authority)) return null;
|
||||
|
||||
var port = DefaultEipPort;
|
||||
var colonIdx = authority.LastIndexOf(':');
|
||||
string gateway;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
gateway = authority[..colonIdx];
|
||||
if (!int.TryParse(authority[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
gateway = authority;
|
||||
}
|
||||
if (string.IsNullOrEmpty(gateway)) return null;
|
||||
|
||||
return new AbLegacyHostAddress(gateway, port, cipPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
|
||||
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
|
||||
/// </summary>
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map libplctag return/status codes. Same polarity as the AbCip mapper — 0 success,
|
||||
/// positive pending, negative error families.
|
||||
/// </summary>
|
||||
public static uint MapLibplctagStatus(int status)
|
||||
{
|
||||
if (status == 0) return Good;
|
||||
if (status > 0) return GoodMoreData;
|
||||
return status switch
|
||||
{
|
||||
-5 => BadTimeout,
|
||||
-7 => BadCommunicationError,
|
||||
-14 => BadNodeIdUnknown,
|
||||
-16 => BadNotWritable,
|
||||
-17 => BadOutOfRange,
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
|
||||
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
|
||||
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
|
||||
/// </summary>
|
||||
public static uint MapPcccStatus(byte sts) => sts switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x10 => BadNotSupported,
|
||||
0x20 => BadNodeIdUnknown,
|
||||
0x30 => BadNotWritable,
|
||||
0x40 => BadDeviceFailure,
|
||||
0x50 => BadDeviceFailure,
|
||||
0xF0 => BadInternalError, // extended status not inspected at this layer
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over a single PCCC tag. Mirrors <c>IAbCipTagRuntime</c>'s shape so
|
||||
/// the same test-fake pattern applies; the only meaningful difference is the protocol layer
|
||||
/// underneath (<c>ab_pccc</c> vs <c>ab_eip</c>).
|
||||
/// </summary>
|
||||
public interface IAbLegacyTagRuntime : IDisposable
|
||||
{
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
Task ReadAsync(CancellationToken cancellationToken);
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
int GetStatus();
|
||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
{
|
||||
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
|
||||
}
|
||||
|
||||
public sealed record AbLegacyTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
@@ -0,0 +1,101 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag-backed <see cref="IAbLegacyTagRuntime"/>. Uses <c>ab_pccc</c> protocol
|
||||
/// on top of EtherNet/IP — libplctag's PCCC layer handles the file-letter + word + bit +
|
||||
/// sub-element decoding internally, so our wrapper just has to forward the atomic type to
|
||||
/// the right Get/Set call.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = p.Gateway,
|
||||
Path = p.CipPath,
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(0),
|
||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||
AbLegacyDataType.String => _tag.GetString(0),
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Bit:
|
||||
if (bitIndex is int)
|
||||
// Bit-within-word writes are routed at the driver level
|
||||
// (AbLegacyDriver.WriteBitInWordAsync) via a parallel parent-word runtime —
|
||||
// this branch only fires if dispatch was bypassed. Throw loudly rather than
|
||||
// silently clobbering the whole word.
|
||||
throw new NotSupportedException(
|
||||
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
|
||||
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||
break;
|
||||
case AbLegacyDataType.Int:
|
||||
case AbLegacyDataType.AnalogInt:
|
||||
_tag.SetInt16(0, Convert.ToInt16(value));
|
||||
break;
|
||||
case AbLegacyDataType.Long:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbLegacyDataType.Float:
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbLegacyDataType.String:
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbLegacyDataType.TimerElement:
|
||||
case AbLegacyDataType.CounterElement:
|
||||
case AbLegacyDataType.ControlElement:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"slc500" => PlcType.Slc500,
|
||||
"micrologix" => PlcType.MicroLogix,
|
||||
"plc5" => PlcType.Plc5,
|
||||
"logixpccc" => PlcType.LogixPccc,
|
||||
_ => PlcType.Slc500,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new LibplctagLegacyTagRuntime(createParams);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family libplctag defaults for PCCC PLCs. SLC 500 / MicroLogix / PLC-5 / LogixPccc
|
||||
/// (Logix controller accessed via the PLC-5 compatibility layer — rare but real).
|
||||
/// </summary>
|
||||
public sealed record AbLegacyPlcFamilyProfile(
|
||||
string LibplctagPlcAttribute,
|
||||
string DefaultCipPath,
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
{
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
AbLegacyPlcFamily.MicroLogix => MicroLogix,
|
||||
AbLegacyPlcFamily.Plc5 => Plc5,
|
||||
AbLegacyPlcFamily.LogixPccc => LogixPccc,
|
||||
_ => Slc500,
|
||||
};
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Slc500 = new(
|
||||
LibplctagPlcAttribute: "slc500",
|
||||
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+
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
/// Rare but real — some legacy HMI integrations address Logix controllers as if they were
|
||||
/// PLC-5 via the PCCC-passthrough mechanism.
|
||||
/// </summary>
|
||||
public static readonly AbLegacyPlcFamilyProfile LogixPccc = new(
|
||||
LibplctagPlcAttribute: "logixpccc",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: true);
|
||||
}
|
||||
|
||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||
public enum AbLegacyPlcFamily
|
||||
{
|
||||
Slc500,
|
||||
MicroLogix,
|
||||
Plc5,
|
||||
LogixPccc,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- libplctag — ab_pccc protocol for SLC 500 / MicroLogix / PLC-5 / LogixPccc.
|
||||
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
|
||||
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
|
||||
<PackageReference Include="libplctag" Version="1.5.2"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal file
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
{
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
}
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS atomic data types. Narrower than Logix/IEC — FANUC CNCs expose mostly integer +
|
||||
/// floating-point data with no UDT concept; macro variables are double-precision floats
|
||||
/// and PMC reads return byte / signed word / signed dword.
|
||||
/// </summary>
|
||||
public enum FocasDataType
|
||||
{
|
||||
/// <summary>Single bit (PMC bit, or bit within a CNC parameter).</summary>
|
||||
Bit,
|
||||
/// <summary>8-bit signed byte (PMC 1-byte read).</summary>
|
||||
Byte,
|
||||
/// <summary>16-bit signed word (PMC 2-byte read, or CNC parameter as short).</summary>
|
||||
Int16,
|
||||
/// <summary>32-bit signed int (PMC 4-byte read, or CNC parameter as int).</summary>
|
||||
Int32,
|
||||
/// <summary>32-bit IEEE-754 float (rare; some CNC macro variables).</summary>
|
||||
Float32,
|
||||
/// <summary>64-bit IEEE-754 double (most macro variables are double-precision).</summary>
|
||||
Float64,
|
||||
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
|
||||
String,
|
||||
}
|
||||
|
||||
public static class FocasDataTypeExtensions
|
||||
{
|
||||
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit => DriverDataType.Boolean,
|
||||
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDataType.Float32 => DriverDataType.Float32,
|
||||
FocasDataType.Float64 => DriverDataType.Float64,
|
||||
FocasDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
344
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
344
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
|
||||
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
|
||||
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
|
||||
/// and cannot be redistributed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
|
||||
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
|
||||
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
|
||||
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = FocasHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
internal int DeviceCount => _devices.Count;
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = FocasAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == FocasStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"FOCAS status 0x{status:X8} reading {reference}");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = FocasAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("FOCAS", "FOCAS");
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is { IsConnected: true } c) return c;
|
||||
device.Client ??= _clientFactory.Create();
|
||||
try
|
||||
{
|
||||
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
device.Client.Dispose();
|
||||
device.Client = null;
|
||||
throw;
|
||||
}
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public FocasDeviceOptions Options { get; } = options;
|
||||
public IFocasClient? Client { get; set; }
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
Client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
|
||||
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
|
||||
/// Phase 6.1 resilience layer.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverOptions
|
||||
{
|
||||
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class FocasProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal file
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS target address — IP + TCP port. Canonical <c>focas://{ip}[:{port}]</c>.
|
||||
/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port).
|
||||
/// </summary>
|
||||
public sealed record FocasHostAddress(string Host, int Port)
|
||||
{
|
||||
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
|
||||
public const int DefaultPort = 8193;
|
||||
|
||||
public override string ToString() => Port == DefaultPort
|
||||
? $"focas://{Host}"
|
||||
: $"focas://{Host}:{Port}";
|
||||
|
||||
public static FocasHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "focas://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var body = value[prefix.Length..];
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
|
||||
var colonIdx = body.LastIndexOf(':');
|
||||
string host;
|
||||
var port = DefaultPort;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
host = body[..colonIdx];
|
||||
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = body;
|
||||
}
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
return new FocasHostAddress(host, port);
|
||||
}
|
||||
}
|
||||
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal file
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an
|
||||
/// <c>EW_*</c> constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation
|
||||
/// (<c>EW_OK = 0</c>, <c>EW_NUMBER</c>, <c>EW_SOCKET</c>, etc.). Mirrors the shape of the
|
||||
/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers.
|
||||
/// </summary>
|
||||
public static class FocasStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
|
||||
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
|
||||
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
|
||||
/// </summary>
|
||||
public static uint MapFocasReturn(int ret) => ret switch
|
||||
{
|
||||
0 => Good,
|
||||
1 => BadNotSupported, // EW_FUNC — CNC does not support this function
|
||||
2 => BadOutOfRange, // EW_OVRFLOW
|
||||
3 => BadOutOfRange, // EW_NUMBER
|
||||
4 => BadOutOfRange, // EW_LENGTH
|
||||
5 => BadNotWritable, // EW_PROT
|
||||
6 => BadNotSupported, // EW_NOOPT — optional CNC feature missing
|
||||
7 => BadTypeMismatch, // EW_ATTRIB
|
||||
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
|
||||
9 => BadCommunicationError, // EW_PARITY
|
||||
11 => BadNotWritable, // EW_PASSWD
|
||||
-1 => BadDeviceFailure, // EW_BUSY
|
||||
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
|
||||
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
|
||||
-10 => BadCommunicationError, // EW_UNEXP
|
||||
-16 => BadCommunicationError, // EW_SOCKET
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
328
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
Normal file
328
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
|
||||
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
|
||||
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
|
||||
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
|
||||
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
|
||||
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
|
||||
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
|
||||
/// mapping.</para>
|
||||
///
|
||||
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
|
||||
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
|
||||
/// calls <c>cnc_freelibhndl</c>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FwlibFocasClient : IFocasClient
|
||||
{
|
||||
private ushort _handle;
|
||||
private bool _connected;
|
||||
|
||||
// Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
|
||||
// concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
|
||||
_rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return Task.CompletedTask;
|
||||
|
||||
var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
|
||||
var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
|
||||
_handle = handle;
|
||||
_connected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
|
||||
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Pmc => WritePmc(address, type, value),
|
||||
FocasAreaKind.Parameter => WriteParameter(address, type, value),
|
||||
FocasAreaKind.Macro => WriteMacro(address, value),
|
||||
_ => FocasStatusMapper.BadNotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
|
||||
/// concurrent bit writes against the same byte serialise and neither loses its update.
|
||||
/// </summary>
|
||||
private async Task<uint> WritePmcBitAsync(
|
||||
FocasAddress address, bool newValue, CancellationToken cancellationToken)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var bit = address.BitIndex ?? 0;
|
||||
if (bit is < 0 or > 7)
|
||||
throw new InvalidOperationException(
|
||||
$"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
|
||||
|
||||
var rmwLock = GetRmwLock(addrType, address.Number);
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Read the parent byte.
|
||||
var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var readRet = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
|
||||
if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
|
||||
|
||||
var current = readBuf.Data[0];
|
||||
var updated = newValue
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
|
||||
// Write the updated byte.
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
writeBuf.Data[0] = updated;
|
||||
|
||||
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
|
||||
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcReadLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, dataType,
|
||||
(ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)buf.Data[0],
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcWriteLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = dataType,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
EncodePmcValue(buf.Data, type, value, address.BitIndex);
|
||||
|
||||
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = ParamReadLength(type);
|
||||
var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD
|
||||
{
|
||||
Datano = (short)address.Number,
|
||||
Type = 0,
|
||||
Data = new byte[32],
|
||||
};
|
||||
var length = ParamReadLength(type);
|
||||
EncodeParamValue(buf.Data, type, value);
|
||||
var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
// Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
|
||||
// scaled value regardless of the decimal-point count the CNC reports.
|
||||
var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
|
||||
return (scaled, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteMacro(FocasAddress address, object? value)
|
||||
{
|
||||
// Write as integer + 0 decimal places — callers that need decimal precision can extend
|
||||
// this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
|
||||
var intValue = Convert.ToInt32(value);
|
||||
var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try { FwlibNative.FreeLibHndl(_handle); } catch { }
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int PmcReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
|
||||
FocasDataType.Int16 => 8 + 2,
|
||||
FocasDataType.Int32 => 8 + 4,
|
||||
FocasDataType.Float32 => 8 + 4,
|
||||
FocasDataType.Float64 => 8 + 8,
|
||||
_ => 8 + 1,
|
||||
};
|
||||
|
||||
private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
|
||||
private static int ParamReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
|
||||
|
||||
internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Bit:
|
||||
// PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
|
||||
// upstream. This branch only fires when a caller passes Bit with no bitIndex —
|
||||
// treat the value as a whole-byte boolean (non-zero / zero).
|
||||
data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
|
||||
break;
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
case FocasDataType.Float32:
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
|
||||
break;
|
||||
case FocasDataType.Float64:
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
|
||||
}
|
||||
_ = bitIndex; // bit-in-byte handled above
|
||||
}
|
||||
|
||||
internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
|
||||
public sealed class FwlibFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FwlibFocasClient();
|
||||
}
|
||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
|
||||
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
|
||||
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
|
||||
/// or on <c>PATH</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
|
||||
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
|
||||
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
|
||||
/// are added.
|
||||
/// </remarks>
|
||||
internal static class FwlibNative
|
||||
{
|
||||
private const string Library = "Fwlib32.dll";
|
||||
|
||||
// ---- Handle lifetime ----
|
||||
|
||||
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short AllcLibHndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
|
||||
public static extern short FreeLibHndl(ushort handle);
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
|
||||
[DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcRdPmcRng(
|
||||
ushort handle,
|
||||
short addrType,
|
||||
short dataType,
|
||||
ushort startNumber,
|
||||
ushort endNumber,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcWrPmcRng(
|
||||
ushort handle,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
// ---- Parameters ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
|
||||
public static extern short RdParam(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
|
||||
public static extern short WrParam(
|
||||
ushort handle,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
public static extern short RdMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
ref ODBM buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
|
||||
public static extern short WrMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
int macroValue,
|
||||
short decimalPointCount);
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
|
||||
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPMC
|
||||
{
|
||||
public short TypeA;
|
||||
public short TypeD;
|
||||
public ushort DatanoS;
|
||||
public ushort DatanoE;
|
||||
// 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
|
||||
/// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPSD
|
||||
{
|
||||
public short Datano;
|
||||
public short Type; // axis index (0 for non-axis)
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBM
|
||||
{
|
||||
public short Datano;
|
||||
public short Dummy;
|
||||
public int McrVal; // long in C; 32-bit signed
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
{
|
||||
public short Dummy;
|
||||
public short TmMode;
|
||||
public short Aut;
|
||||
public short Run;
|
||||
public short Motion;
|
||||
public short Mstb;
|
||||
public short Emergency;
|
||||
public short Alarm;
|
||||
public short Edit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Per Fanuc FOCAS/2 spec the codes
|
||||
/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
|
||||
/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
|
||||
/// </summary>
|
||||
internal static class FocasPmcAddrType
|
||||
{
|
||||
public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => 0,
|
||||
"F" => 1,
|
||||
"Y" => 2,
|
||||
"X" => 3,
|
||||
"A" => 4,
|
||||
"R" => 5,
|
||||
"T" => 6,
|
||||
"K" => 7,
|
||||
"C" => 8,
|
||||
"D" => 9,
|
||||
"E" => 10,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
|
||||
internal static class FocasPmcDataType
|
||||
{
|
||||
public const short Byte = 0;
|
||||
public const short Word = 1;
|
||||
public const short Long = 2;
|
||||
public const short Float = 4;
|
||||
public const short Double = 5;
|
||||
|
||||
public static short FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => Byte,
|
||||
FocasDataType.Int16 => Word,
|
||||
FocasDataType.Int32 => Long,
|
||||
FocasDataType.Float32 => Float,
|
||||
FocasDataType.Float64 => Double,
|
||||
_ => Byte,
|
||||
};
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
|
||||
/// configured device; lifetime matches the device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
|
||||
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
|
||||
/// cannot legally be redistributed. The deployment team supplies an
|
||||
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
|
||||
/// P/Invoke and registers it at server startup.</para>
|
||||
///
|
||||
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
|
||||
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
|
||||
/// mysteriously hanging.</para>
|
||||
/// </remarks>
|
||||
public interface IFocasClient : IDisposable
|
||||
{
|
||||
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
|
||||
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read the value at <paramref name="address"/> in the requested
|
||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||
/// through <see cref="FocasStatusMapper"/>.
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
|
||||
/// OPC UA status (0 = Good).
|
||||
/// </summary>
|
||||
Task<uint> WriteAsync(
|
||||
FocasAddress address,
|
||||
FocasDataType type,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
IFocasClient Create();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that throws at construction time — the deployment must register a real
|
||||
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
|
||||
/// compile + the abstraction tests to run.
|
||||
/// </summary>
|
||||
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => throw new NotSupportedException(
|
||||
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
|
||||
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
|
||||
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
|
||||
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
|
||||
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
|
||||
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
|
||||
Follow-up task #193 tracks the real-client reference implementation that customers may
|
||||
drop in privately.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -264,8 +264,27 @@ public sealed class ModbusDriver
|
||||
return results;
|
||||
}
|
||||
|
||||
// BitInRegister writes need a read-modify-write against the full holding register. A
|
||||
// per-register lock keeps concurrent bit-write callers from stomping on each other —
|
||||
// Write bit 0 and Write bit 5 targeting the same register can arrive on separate
|
||||
// subscriber threads, and without serialising the RMW the second-to-commit value wins
|
||||
// + the first bit update is lost.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(ushort address) =>
|
||||
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
|
||||
// write sequence doesn't hit EncodeRegister's defensive throw.
|
||||
if (tag.DataType == ModbusDataType.BitInRegister &&
|
||||
tag.Region is ModbusRegion.HoldingRegisters)
|
||||
{
|
||||
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
@@ -309,6 +328,44 @@ public sealed class ModbusDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
|
||||
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
|
||||
/// </summary>
|
||||
private async Task WriteBitInRegisterAsync(
|
||||
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
var bit = tag.BitIndex;
|
||||
if (bit > 15)
|
||||
throw new InvalidOperationException(
|
||||
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
|
||||
var on = Convert.ToBoolean(value);
|
||||
|
||||
var rmwLock = GetRmwLock(tag.Address);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// FC03 read 1 holding register at tag.Address.
|
||||
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var readResp = await transport.SendAsync(_options.UnitId, readPdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count=2][hi][lo]
|
||||
var current = (ushort)((readResp[2] << 8) | readResp[3]);
|
||||
|
||||
var updated = on
|
||||
? (ushort)(current | (1 << bit))
|
||||
: (ushort)(current & ~(1 << bit));
|
||||
|
||||
// FC06 write single holding register.
|
||||
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(updated >> 8), (byte)(updated & 0xFF) };
|
||||
await transport.SendAsync(_options.UnitId, writePdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -575,8 +632,11 @@ public sealed class ModbusDriver
|
||||
return b;
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
|
||||
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
|
||||
// the RMW shortcut in WriteOneAsync.
|
||||
throw new InvalidOperationException(
|
||||
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
|
||||
287
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
287
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TwinCAT;
|
||||
using TwinCAT.Ads;
|
||||
using TwinCAT.Ads.TypeSystem;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ITwinCATClient"/> backed by Beckhoff's <see cref="AdsClient"/>.
|
||||
/// One instance per AMS target; reused across reads / writes / probes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire behavior depends on a reachable AMS router — on Windows the router comes
|
||||
/// from TwinCAT XAR; elsewhere from the <c>Beckhoff.TwinCAT.Ads.TcpRouter</c> package
|
||||
/// hosted by the server process. Neither is built-in here; deployment wires one in.</para>
|
||||
///
|
||||
/// <para>Error mapping — ADS error codes surface through <see cref="AdsErrorException"/>
|
||||
/// and get translated to OPC UA status codes via <see cref="TwinCATStatusMapper.MapAdsError"/>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
private readonly AdsClient _client = new();
|
||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||
|
||||
public AdsTwinCATClient()
|
||||
{
|
||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||
}
|
||||
|
||||
public bool IsConnected => _client.IsConnected;
|
||||
|
||||
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client.IsConnected) return Task.CompletedTask;
|
||||
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
|
||||
var netId = AmsNetId.Parse(address.NetId);
|
||||
_client.Connect(netId, address.Port);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
object? value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bitIndex is int && type == TwinCATDataType.Bool)
|
||||
throw new NotSupportedException(
|
||||
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
|
||||
|
||||
try
|
||||
{
|
||||
var converted = ConvertForWrite(type, value);
|
||||
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return result.ErrorCode == AdsErrorCode.NoError
|
||||
? TwinCATStatusMapper.Good
|
||||
: TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
return state.ErrorCode == AdsErrorCode.NoError;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
// NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange
|
||||
// fires when the value differs; OnCycle fires every cycle. OnChange is the right default
|
||||
// for OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// changed" so we let it decide.
|
||||
var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0);
|
||||
|
||||
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
// key into _notifications.
|
||||
var result = await _client.AddDeviceNotificationExAsync(
|
||||
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
throw new InvalidOperationException(
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
}
|
||||
|
||||
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
|
||||
{
|
||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
||||
}
|
||||
|
||||
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_notifications.TryRemove(handle, out _);
|
||||
try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort tear-down; target may already be gone */ }
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||||
// async surface on this interface is for our callers, not for the underlying call which
|
||||
// is effectively sync on top of the already-open AdsClient.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||||
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
|
||||
private static bool IsSymbolWritable(ISymbol symbol)
|
||||
{
|
||||
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
|
||||
// When the symbol implementation doesn't surface it, assume writable + let the PLC
|
||||
// return AccessDenied at write time.
|
||||
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||
_notifications.Clear();
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NotificationRegistration(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
Action<string, object?> onChange,
|
||||
AdsTwinCATClient owner,
|
||||
uint handle) : ITwinCATNotificationHandle
|
||||
{
|
||||
public string SymbolPath { get; } = symbolPath;
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Fire-and-forget AMS call — caller has already committed to the tear-down.
|
||||
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static Type MapToClrType(TwinCATDataType type) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => typeof(bool),
|
||||
TwinCATDataType.SInt => typeof(sbyte),
|
||||
TwinCATDataType.USInt => typeof(byte),
|
||||
TwinCATDataType.Int => typeof(short),
|
||||
TwinCATDataType.UInt => typeof(ushort),
|
||||
TwinCATDataType.DInt => typeof(int),
|
||||
TwinCATDataType.UDInt => typeof(uint),
|
||||
TwinCATDataType.LInt => typeof(long),
|
||||
TwinCATDataType.ULInt => typeof(ulong),
|
||||
TwinCATDataType.Real => typeof(float),
|
||||
TwinCATDataType.LReal => typeof(double),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => typeof(string),
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => typeof(uint),
|
||||
_ => typeof(int),
|
||||
};
|
||||
|
||||
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||
TwinCATDataType.USInt => Convert.ToByte(value),
|
||||
TwinCATDataType.Int => Convert.ToInt16(value),
|
||||
TwinCATDataType.UInt => Convert.ToUInt16(value),
|
||||
TwinCATDataType.DInt => Convert.ToInt32(value),
|
||||
TwinCATDataType.UDInt => Convert.ToUInt32(value),
|
||||
TwinCATDataType.LInt => Convert.ToInt64(value),
|
||||
TwinCATDataType.ULInt => Convert.ToUInt64(value),
|
||||
TwinCATDataType.Real => Convert.ToSingle(value),
|
||||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
||||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||
};
|
||||
|
||||
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||||
{
|
||||
short s => (s & (1 << bit)) != 0,
|
||||
ushort us => (us & (1 << bit)) != 0,
|
||||
int i => (i & (1 << bit)) != 0,
|
||||
uint ui => (ui & (1u << bit)) != 0,
|
||||
long l => (l & (1L << bit)) != 0,
|
||||
ulong ul => (ul & (1UL << bit)) != 0,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
|
||||
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
|
||||
{
|
||||
public ITwinCATClient Create() => new AdsTwinCATClient();
|
||||
}
|
||||
100
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
100
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over one connection to a TwinCAT AMS target. One instance per
|
||||
/// <see cref="TwinCATAmsAddress"/>; reused across reads / writes / probes for the device.
|
||||
/// Tests swap in a fake via <see cref="ITwinCATClientFactory"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike libplctag-backed drivers where one native handle exists per tag, TwinCAT's
|
||||
/// AdsClient is one connection per target with symbolic reads / writes issued against it.
|
||||
/// The abstraction reflects that — single <see cref="ConnectAsync"/>, many
|
||||
/// <see cref="ReadValueAsync"/> / <see cref="WriteValueAsync"/> calls.
|
||||
/// </remarks>
|
||||
public interface ITwinCATClient : IDisposable
|
||||
{
|
||||
/// <summary>Establish the AMS connection. Idempotent — subsequent calls are no-ops when already connected.</summary>
|
||||
Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the AMS router + target both accept commands.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
/// </summary>
|
||||
Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose
|
||||
/// <see cref="IDisposable.Dispose"/> tears the notification down. Callback fires on the
|
||||
/// thread libplctag / AdsClient uses for notifications — consumers should marshal to
|
||||
/// their own scheduler before doing work of any size.
|
||||
/// </summary>
|
||||
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
|
||||
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
|
||||
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
|
||||
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
|
||||
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
TimeSpan cycleTime,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode).
|
||||
/// Yields each top-level symbol the PLC exposes — global variables, program-scope locals,
|
||||
/// function-block instance fields. Filters for our atomic type surface; structured /
|
||||
/// UDT / function-block typed symbols surface with <c>DataType = null</c> so callers can
|
||||
/// decide whether to drill in via their own walker.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||
public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
/// <summary>
|
||||
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
|
||||
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
|
||||
public sealed record TwinCATDiscoveredSymbol(
|
||||
string InstancePath,
|
||||
TwinCATDataType? DataType,
|
||||
bool ReadOnly);
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
{
|
||||
ITwinCATClient Create();
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed TwinCAT AMS address — six-octet AMS Net ID + port. Canonical form
|
||||
/// <c>ads://{netId}:{port}</c> where <c>netId</c> is five-dot-separated octets (six of them)
|
||||
/// and <c>port</c> is the AMS service port (851 = TC3 PLC runtime 1, 852 = runtime 2, 801 /
|
||||
/// 811 / 821 = TC2 PLC runtimes, 10000 = system service, etc.).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format examples:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ads://5.23.91.23.1.1:851</c> — remote TC3 runtime</item>
|
||||
/// <item><c>ads://5.23.91.23.1.1</c> — defaults to port 851 (TC3 PLC runtime 1)</item>
|
||||
/// <item><c>ads://127.0.0.1.1.1:851</c> — local loopback (when the router is local)</item>
|
||||
/// </list>
|
||||
/// <para>AMS Net ID is NOT an IP — it's a Beckhoff-specific identifier that the router
|
||||
/// translates to an IP route. Typically the first four octets match the host's IPv4 and
|
||||
/// the last two are <c>.1.1</c>, but the router can be configured otherwise.</para>
|
||||
/// </remarks>
|
||||
public sealed record TwinCATAmsAddress(string NetId, int Port)
|
||||
{
|
||||
/// <summary>Default AMS port — TC3 PLC runtime 1.</summary>
|
||||
public const int DefaultPlcPort = 851;
|
||||
|
||||
public override string ToString() => Port == DefaultPlcPort
|
||||
? $"ads://{NetId}"
|
||||
: $"ads://{NetId}:{Port}";
|
||||
|
||||
public static TwinCATAmsAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "ads://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var body = value[prefix.Length..];
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
|
||||
var colonIdx = body.LastIndexOf(':');
|
||||
string netId;
|
||||
var port = DefaultPlcPort;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
netId = body[..colonIdx];
|
||||
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
netId = body;
|
||||
}
|
||||
|
||||
if (!IsValidNetId(netId)) return null;
|
||||
return new TwinCATAmsAddress(netId, port);
|
||||
}
|
||||
|
||||
private static bool IsValidNetId(string netId)
|
||||
{
|
||||
var parts = netId.Split('.');
|
||||
if (parts.Length != 6) return false;
|
||||
foreach (var p in parts)
|
||||
if (!byte.TryParse(p, out _)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT / IEC 61131-3 atomic data types. Wider type surface than Logix because IEC adds
|
||||
/// <c>WSTRING</c> (UTF-16) and <c>TIME</c>/<c>DATE</c>/<c>DT</c>/<c>TOD</c> variants.
|
||||
/// </summary>
|
||||
public enum TwinCATDataType
|
||||
{
|
||||
Bool,
|
||||
SInt, // signed 8-bit
|
||||
USInt, // unsigned 8-bit
|
||||
Int, // signed 16-bit
|
||||
UInt, // unsigned 16-bit
|
||||
DInt, // signed 32-bit
|
||||
UDInt, // unsigned 32-bit
|
||||
LInt, // signed 64-bit
|
||||
ULInt, // unsigned 64-bit
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // ASCII string
|
||||
WString,// UTF-16 string
|
||||
Time, // TIME — ms since epoch of day, stored as UDINT
|
||||
Date, // DATE — days since 1970-01-01, stored as UDINT
|
||||
DateTime, // DT — seconds since 1970-01-01, stored as UDINT
|
||||
TimeOfDay,// TOD — ms since midnight, stored as UDINT
|
||||
/// <summary>UDT / FB instance. Resolved per member at discovery time.</summary>
|
||||
Structure,
|
||||
}
|
||||
|
||||
public static class TwinCATDataTypeExtensions
|
||||
{
|
||||
public static DriverDataType ToDriverDataType(this TwinCATDataType t) => t switch
|
||||
{
|
||||
TwinCATDataType.Bool => DriverDataType.Boolean,
|
||||
TwinCATDataType.SInt or TwinCATDataType.USInt
|
||||
or TwinCATDataType.Int or TwinCATDataType.UInt
|
||||
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
||||
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
|
||||
TwinCATDataType.Real => DriverDataType.Float32,
|
||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
|
||||
TwinCATDataType.Structure => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
451
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
451
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Normal file
@@ -0,0 +1,451 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships
|
||||
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly TwinCATDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ITwinCATClientFactory _clientFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||
ITwinCATClientFactory? clientFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "TwinCAT";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Native subs first — disposing the handles is cheap + lets the client close its
|
||||
// notifications before the AdsClient itself goes away.
|
||||
foreach (var sub in _nativeSubs.Values)
|
||||
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||
_nativeSubs.Clear();
|
||||
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
internal int DeviceCount => _devices.Count;
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("TwinCAT", "TwinCAT");
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
|
||||
// Pre-declared tags — always emitted as the authoritative config path.
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
|
||||
// client-side error so a flaky symbol-table download doesn't block discovery.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
|
||||
FullName: sym.InstancePath,
|
||||
DriverDataType: dt.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: sym.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// Symbol-loader failure is non-fatal to discovery — pre-declared tags already
|
||||
// shipped + operators see the failure in driver health on next read.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (native ADS notifications with poll fallback) ----
|
||||
|
||||
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
|
||||
private long _nextNativeSubId;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
|
||||
/// is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
|
||||
/// Native path registers one <see cref="ITwinCATNotificationHandle"/> per tag against the
|
||||
/// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll
|
||||
/// loop entirely. Unsub path disposes the handles.
|
||||
/// </summary>
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.UseNativeNotifications)
|
||||
return _poll.Subscribe(fullReferences, publishingInterval);
|
||||
|
||||
var id = Interlocked.Increment(ref _nextNativeSubId);
|
||||
var handle = new NativeSubscriptionHandle(id);
|
||||
var registrations = new List<ITwinCATNotificationHandle>(fullReferences.Count);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var reference in fullReferences)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
|
||||
var reg = await client.AddNotificationAsync(
|
||||
symbolName, def.DataType, bitIndex, publishingInterval,
|
||||
(_, value) => OnDataChange?.Invoke(this,
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
registrations.Add(reg);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On any registration failure, tear down everything we got so far + rethrow. Leaves
|
||||
// the subscription in a clean "never existed" state rather than a half-registered
|
||||
// state the caller has to clean up.
|
||||
foreach (var r in registrations) { try { r.Dispose(); } catch { } }
|
||||
throw;
|
||||
}
|
||||
|
||||
_nativeSubs[id] = new NativeSubscription(handle, registrations);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub))
|
||||
{
|
||||
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"twincat-native-sub-{Id}";
|
||||
}
|
||||
|
||||
private sealed record NativeSubscription(
|
||||
NativeSubscriptionHandle Handle,
|
||||
IReadOnlyList<ITwinCATNotificationHandle> Registrations);
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch
|
||||
{
|
||||
// Probe failure — EnsureConnectedAsync's connect-failure path already disposed
|
||||
// + cleared the client, so next tick will reconnect.
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
private async Task<ITwinCATClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is { IsConnected: true } c) return c;
|
||||
device.Client ??= _clientFactory.Create();
|
||||
try
|
||||
{
|
||||
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
device.Client.Dispose();
|
||||
device.Client = null;
|
||||
throw;
|
||||
}
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
|
||||
{
|
||||
public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress;
|
||||
public TwinCATDeviceOptions Options { get; } = options;
|
||||
public ITwinCATClient? Client { get; set; }
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
Client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
62
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT ADS driver configuration. One instance supports N targets (each identified by
|
||||
/// an AMS Net ID + port). Compiles + runs without a local AMS router but every wire call
|
||||
/// fails with <c>BadCommunicationError</c> until a router is reachable.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriverOptions
|
||||
{
|
||||
public IReadOnlyList<TwinCATDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
|
||||
public TwinCATProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default), <c>SubscribeAsync</c> registers native ADS notifications
|
||||
/// via <c>AddDeviceNotificationExAsync</c> — the PLC pushes changes on its own cycle
|
||||
/// rather than the driver polling. Strictly better for latency + CPU when the target
|
||||
/// supports it (TC2 + TC3 PLC runtimes always do; some soft-PLC / third-party ADS
|
||||
/// implementations may not). When <c>false</c>, the driver falls through to the shared
|
||||
/// <see cref="Core.Abstractions.PollGroupEngine"/> — same semantics as the other
|
||||
/// libplctag-backed drivers. Set <c>false</c> for deployments where the AMS router has
|
||||
/// notification limits you can't raise.
|
||||
/// </summary>
|
||||
public bool UseNativeNotifications { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's symbol table via the
|
||||
/// TwinCAT <c>SymbolLoaderFactory</c> (flat mode) + surfaces controller-resident
|
||||
/// globals / program locals under a <c>Discovered/</c> sub-folder. Pre-declared tags
|
||||
/// from <see cref="Tags"/> always emit regardless. Default <c>false</c> to preserve
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One TwinCAT target. <paramref name="HostAddress"/> must parse via
|
||||
/// <see cref="TwinCATAmsAddress.TryParse"/>; misconfigured devices fail driver initialisation.
|
||||
/// </summary>
|
||||
public sealed record TwinCATDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
||||
/// </summary>
|
||||
public sealed record TwinCATTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string SymbolPath,
|
||||
TwinCATDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class TwinCATProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
43
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs
Normal file
43
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Maps AMS / ADS error codes to OPC UA StatusCodes. ADS error codes are defined in
|
||||
/// <c>AdsErrorCode</c> from <c>Beckhoff.TwinCAT.Ads</c> — this mapper covers the ones a
|
||||
/// driver actually encounters during normal operation (symbol-not-found, access-denied,
|
||||
/// timeout, router-not-initialized, invalid-group/offset, etc.).
|
||||
/// </summary>
|
||||
public static class TwinCATStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map an AMS / ADS error code (uint from AdsErrorCode enum). 0 = success; non-zero
|
||||
/// codes follow Beckhoff's AMS error table (7 = target port not found, 1792 =
|
||||
/// ADSERR_DEVICE_SRVNOTSUPP, 1793 = ADSERR_DEVICE_INVALIDGRP, 1794 =
|
||||
/// ADSERR_DEVICE_INVALIDOFFSET, 1798 = ADSERR_DEVICE_SYMBOLNOTFOUND, 1808 =
|
||||
/// ADSERR_DEVICE_ACCESSDENIED, 1861 = ADSERR_CLIENT_SYNCTIMEOUT).
|
||||
/// </summary>
|
||||
public static uint MapAdsError(uint adsError) => adsError switch
|
||||
{
|
||||
0 => Good,
|
||||
6 or 7 => BadCommunicationError, // target port unreachable
|
||||
1792 => BadNotSupported, // service not supported
|
||||
1793 => BadOutOfRange, // invalid index group
|
||||
1794 => BadOutOfRange, // invalid index offset
|
||||
1798 => BadNodeIdUnknown, // symbol not found
|
||||
1807 => BadDeviceFailure, // device in invalid state
|
||||
1808 => BadNotWritable, // access denied
|
||||
1811 or 1812 => BadOutOfRange, // size mismatch
|
||||
1861 => BadTimeout, // sync timeout
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
103
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
Normal file
103
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed TwinCAT symbolic tag path. Handles global-variable-list (<c>GVL.Counter</c>),
|
||||
/// program-variable (<c>MAIN.bStart</c>), structured member access
|
||||
/// (<c>Motor1.Status.Running</c>), array subscripts (<c>Data[5]</c>), multi-dim arrays
|
||||
/// (<c>Matrix[1,2]</c>), and bit-access (<c>Flags.0</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>TwinCAT's symbolic syntax mirrors IEC 61131-3 structured-text identifiers — so the
|
||||
/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's
|
||||
/// <c>Program:</c> scope prefix. The leading segment is the namespace (POU name /
|
||||
/// GVL name) and subsequent segments walk into struct/array members.</para>
|
||||
/// </remarks>
|
||||
public sealed record TwinCATSymbolPath(
|
||||
IReadOnlyList<TwinCATSymbolSegment> Segments,
|
||||
int? BitIndex)
|
||||
{
|
||||
public string ToAdsSymbolName()
|
||||
{
|
||||
var buf = new System.Text.StringBuilder();
|
||||
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(']');
|
||||
}
|
||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||
return buf.ToString();
|
||||
}
|
||||
|
||||
public static TwinCATSymbolPath? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
var parts = new List<string>();
|
||||
var depth = 0;
|
||||
var start = 0;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
var c = src[i];
|
||||
if (c == '[') depth++;
|
||||
else if (c == ']') depth--;
|
||||
else if (c == '.' && depth == 0)
|
||||
{
|
||||
parts.Add(src[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.Add(src[start..]);
|
||||
if (depth != 0 || parts.Any(string.IsNullOrEmpty)) return null;
|
||||
|
||||
int? bitIndex = null;
|
||||
if (parts.Count >= 2 && int.TryParse(parts[^1], out var maybeBit)
|
||||
&& maybeBit is >= 0 and <= 31
|
||||
&& !parts[^1].Contains('['))
|
||||
{
|
||||
bitIndex = maybeBit;
|
||||
parts.RemoveAt(parts.Count - 1);
|
||||
}
|
||||
|
||||
var segments = new List<TwinCATSymbolSegment>(parts.Count);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
if (!IsValidIdent(part)) return null;
|
||||
segments.Add(new TwinCATSymbolSegment(part, []));
|
||||
continue;
|
||||
}
|
||||
if (!part.EndsWith(']')) return null;
|
||||
var name = part[..bracketIdx];
|
||||
if (!IsValidIdent(name)) return null;
|
||||
var inner = part[(bracketIdx + 1)..^1];
|
||||
var subs = new List<int>();
|
||||
foreach (var tok in inner.Split(','))
|
||||
{
|
||||
if (!int.TryParse(tok, out var n) || n < 0) return null;
|
||||
subs.Add(n);
|
||||
}
|
||||
if (subs.Count == 0) return null;
|
||||
segments.Add(new TwinCATSymbolSegment(name, subs));
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
|
||||
return new TwinCATSymbolPath(segments, bitIndex);
|
||||
}
|
||||
|
||||
private static bool IsValidIdent(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return false;
|
||||
if (!char.IsLetter(s[0]) && s[0] != '_') return false;
|
||||
for (var i = 1; i < s.Length; i++)
|
||||
if (!char.IsLetterOrDigit(s[i]) && s[i] != '_') return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TwinCATSymbolSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Filter system / infrastructure symbols out of a TwinCAT symbol-loader walk. TC PLC
|
||||
/// runtimes export plumbing symbols alongside user-declared ones — <c>TwinCAT_SystemInfoVarList</c>,
|
||||
/// constants, IO task images, motion-layer internals — that clutter an OPC UA address space
|
||||
/// if exposed.
|
||||
/// </summary>
|
||||
public static class TwinCATSystemSymbolFilter
|
||||
{
|
||||
/// <summary><c>true</c> when the symbol path matches a known system / infrastructure prefix.</summary>
|
||||
public static bool IsSystemSymbol(string instancePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instancePath)) return true;
|
||||
|
||||
// Runtime-exported info lists.
|
||||
if (instancePath.StartsWith("TwinCAT_SystemInfoVarList", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (instancePath.StartsWith("TwinCAT_", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (instancePath.StartsWith("Global_Version", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Constants pool — read-only, no operator value.
|
||||
if (instancePath.StartsWith("Constants.", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Anonymous / compiler-generated.
|
||||
if (instancePath.StartsWith("__", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Motion / NC internals routinely surfaced by the symbol loader.
|
||||
if (instancePath.StartsWith("Mc_", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Official Beckhoff ADS client. Requires a running AMS router (TwinCAT XAR, TwinCAT HMI
|
||||
Server, or the standalone Beckhoff.TwinCAT.Ads.TcpRouter package) to reach remote
|
||||
systems. The router is a runtime concern, not a build concern — the library compiles
|
||||
+ runs fine without one; ADS calls just fail with transport errors. -->
|
||||
<PackageReference Include="Beckhoff.TwinCAT.Ads" Version="7.0.172"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -219,4 +219,67 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_RecordsFailure_OnEveryRetry()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-x", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("always fails");
|
||||
}));
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-x");
|
||||
snap.ShouldNotBeNull();
|
||||
var retryCount = TierAOptions.Resolve(DriverCapability.Read).RetryCount;
|
||||
snap!.ConsecutiveFailures.ShouldBe(retryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-b", DriverCapability.Write, TierAOptions);
|
||||
|
||||
var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold; i++)
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
}));
|
||||
}
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-b");
|
||||
snap.ShouldNotBeNull();
|
||||
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_IsolatesCounters_PerHost()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var dead = builder.GetOrCreate("drv-trk", "dead", DriverCapability.Read, TierAOptions);
|
||||
var live = builder.GetOrCreate("drv-trk", "live", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await dead.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("dead");
|
||||
}));
|
||||
await live.ExecuteAsync(async _ => await Task.Yield());
|
||||
|
||||
tracker.TryGet("drv-trk", "dead")!.ConsecutiveFailures.ShouldBeGreaterThan(0);
|
||||
tracker.TryGet("drv-trk", "live").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke tests that exercise the real libplctag stack against a running
|
||||
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally minimal — per-family + per-capability coverage ships in PRs 9–12 once the
|
||||
/// integration harness is CI-ready. This file exists at PR 3 time to prove the wire path
|
||||
/// works end-to-end on developer boxes that have <c>ab_server</c>.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipReadSmokeTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AbServerFixture _fixture = new();
|
||||
|
||||
public async ValueTask InitializeAsync() => await _fixture.InitializeAsync();
|
||||
public async ValueTask DisposeAsync() => await _fixture.DisposeAsync();
|
||||
|
||||
[AbServerFact]
|
||||
public async Task Driver_reads_DInt_from_ab_server()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://127.0.0.1:{_fixture.Port}/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Tags = [new AbCipTagDefinition("Counter", $"ab://127.0.0.1:{_fixture.Port}/1,0", "TestDINT", AbCipDataType.DInt)],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "drv-smoke");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
|
||||
/// the duration of an integration test collection. Binary is expected on PATH; the per-test
|
||||
/// JSON profile is passed via <c>--config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><c>ab_server</c> is a C binary shipped in the same repo as libplctag (see
|
||||
/// <c>test-data-sources.md</c> §2 and plan decision #99). On a developer workstation it's
|
||||
/// built once from source and placed on PATH; in CI we intend to publish a prebuilt Windows
|
||||
/// x64 binary as a GitHub release asset in a follow-up PR so the fixture can download +
|
||||
/// extract it at setup time. Until then every test in this project is skipped when
|
||||
/// <c>ab_server</c> is not locatable.</para>
|
||||
///
|
||||
/// <para>Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix)
|
||||
/// ship under <c>Profiles/</c> and drive the simulator's tag shape — this is where the
|
||||
/// UDT + Program-scope coverage gap will be filled by the hand-rolled stub in PR 6.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbServerFixture : IAsyncLifetime
|
||||
{
|
||||
private Process? _proc;
|
||||
public int Port { get; } = 44818;
|
||||
|
||||
public bool IsAvailable { get; private set; }
|
||||
|
||||
public ValueTask InitializeAsync() => InitializeAsync(default);
|
||||
public ValueTask DisposeAsync() => DisposeAsync(default);
|
||||
|
||||
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (LocateBinary() is not string binary)
|
||||
{
|
||||
IsAvailable = false;
|
||||
return;
|
||||
}
|
||||
IsAvailable = true;
|
||||
|
||||
_proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binary,
|
||||
Arguments = $"--port {Port} --plc controllogix",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
},
|
||||
};
|
||||
_proc.Start();
|
||||
|
||||
// Give the server a moment to accept its listen socket before tests try to connect.
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_proc.Kill(entireProcessTree: true);
|
||||
_proc.WaitForExit(5_000);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
_proc?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
|
||||
/// depend on it should use <see cref="AbServerFact"/> so CI runs without the binary
|
||||
/// simply skip rather than fail.
|
||||
/// </summary>
|
||||
public static string? LocateBinary()
|
||||
{
|
||||
var names = new[] { "ab_server.exe", "ab_server" };
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when <c>ab_server</c> is not available on PATH.
|
||||
/// Integration tests use this instead of <c>[Fact]</c> so a developer box without
|
||||
/// <c>ab_server</c> installed still gets a green run.
|
||||
/// </summary>
|
||||
public sealed class AbServerFactAttribute : FactAttribute
|
||||
{
|
||||
public AbServerFactAttribute()
|
||||
{
|
||||
if (AbServerFixture.LocateBinary() is null)
|
||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipBoolInDIntRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
|
||||
/// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
|
||||
/// which the driver's WriteBitInDIntAsync reads + writes.
|
||||
/// </summary>
|
||||
private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0b0001 },
|
||||
};
|
||||
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", "Motor.Flags.3", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
|
||||
factory.Tags.ShouldContainKey("Motor.Flags");
|
||||
factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
|
||||
factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
|
||||
(updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
|
||||
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
|
||||
.ToArray();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor1.Flags");
|
||||
factory.Tags.ShouldContainKey("Motor2.Flags");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
// Three factory invocations: two bit-selector tags (never used for writes, but the
|
||||
// driver may create them opportunistically) + one shared parent. Assert the parent was
|
||||
// init'd exactly once + used for both writes.
|
||||
factory.Tags["Flags"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
|
||||
builder.Variables.Count.ShouldBe(2);
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_folder_displayname_falls_back_to_host_address()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
|
||||
&& f.DisplayName == "ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreDeclared_system_tags_are_filtered_out()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tags_for_mismatched_device_are_ignored()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
|
||||
new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
|
||||
new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
|
||||
new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_receives_correct_device_params()
|
||||
{
|
||||
var factory = new FakeEnumeratorFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
|
||||
capturedParams.Gateway.ShouldBe("10.0.0.5");
|
||||
capturedParams.Port.ShouldBe(44818);
|
||||
capturedParams.CipPath.ShouldBe("1,2,3");
|
||||
capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
capturedParams.TagName.ShouldBe("@tags");
|
||||
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_enumerator_factory_is_used_when_not_injected()
|
||||
{
|
||||
// Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
|
||||
// (covered by the other tests which instantiate without injecting a factory).
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
drv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("__DEFVAL_X", true)]
|
||||
[InlineData("__DEFAULT_Y", true)]
|
||||
[InlineData("Routine:Main", true)]
|
||||
[InlineData("Task:MainTask", true)]
|
||||
[InlineData("Local:1:I", true)]
|
||||
[InlineData("Map:Alias", true)]
|
||||
[InlineData("Axis:MoveX", true)]
|
||||
[InlineData("Cam:Profile1", true)]
|
||||
[InlineData("MotionGroup:MG0", true)]
|
||||
[InlineData("Motor1", false)]
|
||||
[InlineData("Program:Main.Step", false)]
|
||||
[InlineData("Recipe_2", false)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
|
||||
{
|
||||
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateCache_roundtrip_put_get()
|
||||
{
|
||||
var cache = new AbCipTemplateCache();
|
||||
var shape = new AbCipUdtShape("MyUdt", 32,
|
||||
[
|
||||
new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
|
||||
new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
|
||||
]);
|
||||
cache.Put("ab://10.0.0.5/1,0", 42, shape);
|
||||
|
||||
cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
|
||||
cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_clears_template_cache()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
private readonly AbCipDiscoveredTag[] _tags;
|
||||
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
|
||||
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
||||
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
|
||||
|
||||
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
|
||||
{
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.LastDeviceParams = deviceParams;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverReadTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_read_returns_Good_with_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Customise the fake before the first read so the tag returns 4200.
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(4200);
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime_without_reinitialise()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_during_read_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_and_per_tag_status()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
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.Real),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"A" => new FakeAbCipTag(p) { Value = 42 },
|
||||
"B" => new FakeAbCipTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbCipTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(42);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_read_marks_health_Healthy()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
|
||||
|
||||
await drv.ReadAsync(["Pressure"], CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagCreateParams_are_built_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["Program:P.Counter"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["Slow"], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_each_tag_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
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));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["A"].Disposed.ShouldBeTrue();
|
||||
factory.Tags["B"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
131
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
Normal file
131
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbCip()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbCip");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbCipPlcFamilyProfile.ControlLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_cycles_devices()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbCipPlcFamilyProfile.ControlLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize.ShouldBe(4002);
|
||||
AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbCipPlcFamilyProfile.Micro800.DefaultCipPath.ShouldBe(""); // no backplane routing
|
||||
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
|
||||
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
|
||||
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
|
||||
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlcTagHandle_IsInvalid_for_zero_or_negative_native_id()
|
||||
{
|
||||
PlcTagHandle.FromNative(-5).IsInvalid.ShouldBeTrue();
|
||||
PlcTagHandle.FromNative(0).IsInvalid.ShouldBeTrue();
|
||||
PlcTagHandle.FromNative(42).IsInvalid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlcTagHandle_Dispose_is_idempotent()
|
||||
{
|
||||
var h = PlcTagHandle.FromNative(42);
|
||||
h.Dispose();
|
||||
h.Dispose(); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbCipDataType_maps_atomics_to_driver_types()
|
||||
{
|
||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverWriteTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("does-not-exist", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_maps_to_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("ReadOnly", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ReadOnly", 7)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_write_encodes_and_flushes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", 4200)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Motor1.Speed"].Value.ShouldBe(4200);
|
||||
factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
|
||||
{
|
||||
// Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
|
||||
// WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
|
||||
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)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Type_mismatch_surfaces_BadTypeMismatch()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Force a FormatException inside Convert.ToInt32 via a runtime that forwards to real Convert.
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new RealConvertFake(p),
|
||||
};
|
||||
var drv2 = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
|
||||
}, "drv-2", factory);
|
||||
await drv2.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv2.WriteAsync(
|
||||
[new WriteRequest("Speed", "not-a-number")], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overflow_surfaces_BadOutOfRange()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p) };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Narrow", 1_000_000)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exception_during_write_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ThrowOnWriteFake(p);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_preserves_order_across_success_and_failure()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/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, Writable: false),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/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("UnknownTag", 3),
|
||||
new WriteRequest("C", 4),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(4);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_write()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new CancelOnWriteFake(p);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.WriteAsync([new WriteRequest("Slow", 1)], CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- test-fake variants that exercise the real type / error handling ----
|
||||
|
||||
private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.Int: _ = Convert.ToInt16(value); break;
|
||||
case AbCipDataType.DInt: _ = Convert.ToInt32(value); break;
|
||||
default: _ = Convert.ToInt32(value); break;
|
||||
}
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbCipDataType.Bool && bitIndex is not null)
|
||||
throw new NotSupportedException("bit-in-DINT deferred");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override Task WriteAsync(CancellationToken ct) =>
|
||||
Task.FromException(new InvalidOperationException("wire dropped"));
|
||||
}
|
||||
|
||||
private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override Task WriteAsync(CancellationToken ct) =>
|
||||
Task.FromException(new OperationCanceledException());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipFetchUdtShapeTests
|
||||
{
|
||||
private sealed class FakeTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public byte[] Response { get; set; } = [];
|
||||
public int ReadCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public uint LastTemplateId { get; private set; }
|
||||
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
LastTemplateId = templateInstanceId;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
public List<IAbCipTemplateReader> Readers { get; } = new();
|
||||
public Func<IAbCipTemplateReader>? Customise { get; set; }
|
||||
|
||||
public IAbCipTemplateReader Create()
|
||||
{
|
||||
var r = Customise?.Invoke() ?? new FakeTemplateReader();
|
||||
Readers.Add(r);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
{
|
||||
var headerSize = 12;
|
||||
var blockSize = 8;
|
||||
var strings = new MemoryStream();
|
||||
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
|
||||
Add(name);
|
||||
foreach (var m in members) Add(m.n);
|
||||
var stringsArr = strings.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + i * blockSize;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
|
||||
}
|
||||
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
|
||||
{
|
||||
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("MotorUdt", 8,
|
||||
("Speed", 0xC4, 0, 0),
|
||||
("Enabled", 0xC1, 0, 4)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
|
||||
// Second fetch must hit the cache — no second reader created.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
factory.Readers.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
|
||||
{
|
||||
var callCount = 0;
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
callCount++;
|
||||
var name = callCount == 1 ? "UdtA" : "UdtB";
|
||||
return new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
|
||||
|
||||
a!.TypeName.ShouldBe("UdtA");
|
||||
b!.TypeName.ShouldBe("UdtB");
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
factory.Readers.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
|
||||
// Next call retries (not cached as a failure).
|
||||
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape2.ShouldBeNull();
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new ThrowingTemplateReader(),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_empties_template_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
|
||||
// Next fetch hits the network again.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("fake read failure");
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHostAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/1,4", "10.0.0.5", 44818, "1,4")]
|
||||
[InlineData("ab://10.0.0.5/1,2,2,192.168.50.20,1,0", "10.0.0.5", 44818, "1,2,2,192.168.50.20,1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://plc-01.factory.internal/1,0", "plc-01.factory.internal", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5:44818/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("AB://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")] // case-insensitive scheme
|
||||
public void TryParse_accepts_valid_forms(string input, string gateway, int port, string cipPath)
|
||||
{
|
||||
var parsed = AbCipHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(cipPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("http://10.0.0.5/1,0")] // wrong scheme
|
||||
[InlineData("ab:10.0.0.5/1,0")] // missing //
|
||||
[InlineData("ab://10.0.0.5")] // no path slash
|
||||
[InlineData("ab:///1,0")] // no gateway
|
||||
[InlineData("ab://10.0.0.5:0/1,0")] // invalid port
|
||||
[InlineData("ab://10.0.0.5:65536/1,0")] // port out of range
|
||||
[InlineData("ab://10.0.0.5:abc/1,0")] // non-numeric port
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbCipHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.0.0.5", 44818, "1,0", "ab://10.0.0.5/1,0")]
|
||||
[InlineData("10.0.0.5", 2222, "1,0", "ab://10.0.0.5:2222/1,0")]
|
||||
[InlineData("10.0.0.5", 44818, "", "ab://10.0.0.5/")]
|
||||
public void ToString_canonicalises(string gateway, int port, string path, string expected)
|
||||
{
|
||||
var addr = new AbCipHostAddress(gateway, port, path);
|
||||
addr.ToString().ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_is_stable()
|
||||
{
|
||||
const string input = "ab://plc-01:44818/1,2,2,10.0.0.10,1,0";
|
||||
var parsed = AbCipHostAddress.TryParse(input)!;
|
||||
// Default port is stripped in canonical form; explicit 44818 → becomes default form.
|
||||
parsed.ToString().ShouldBe("ab://plc-01/1,2,2,10.0.0.10,1,0");
|
||||
|
||||
var parsedAgain = AbCipHostAddress.TryParse(parsed.ToString())!;
|
||||
parsedAgain.ShouldBe(parsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Collections.Concurrent;
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHostProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_one_entry_per_device()
|
||||
{
|
||||
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"),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var statuses = drv.GetHostStatuses();
|
||||
statuses.Count.ShouldBe(2);
|
||||
statuses.Select(s => s.HostName).ShouldBe(["ab://10.0.0.5/1,0", "ab://10.0.0.6/1,0"], ignoreOrder: true);
|
||||
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_with_successful_read_transitions_to_Running()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
transitions.Select(t => t.NewState).ShouldContain(HostState.Running);
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_with_read_failure_transitions_to_Stopped()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_disabled_when_Enabled_is_false()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = false, ProbeTagPath = "@raw_cpu_type" },
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_skipped_when_ProbeTagPath_is_null()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null },
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_loops_across_multiple_devices_independently()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
// Device A returns ok, Device B throws on read.
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new FakeAbCipTag(p)
|
||||
: new FakeAbCipTag(p) { ThrowOnRead = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
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"),
|
||||
],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeTagPath = "@raw_cpu_type",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Count >= 2, TimeSpan.FromSeconds(3));
|
||||
|
||||
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.5/1,0" && t.NewState == HostState.Running);
|
||||
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.6/1,0" && t.NewState == HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
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.6/1,0", "B", AbCipDataType.DInt),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("Motor1.Speed").ShouldBe("ab://10.0.0.7/1,0");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipPlcFamilyTests
|
||||
{
|
||||
// ---- ControlLogix ----
|
||||
|
||||
[Fact]
|
||||
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.ControlLogix;
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.DefaultConnectionSize.ShouldBe(4002); // LFO — FW20+
|
||||
p.DefaultCipPath.ShouldBe("1,0");
|
||||
p.SupportsRequestPacking.ShouldBeTrue();
|
||||
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||
p.MaxFragmentBytes.ShouldBe(4000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlLogix_device_initialises_with_correct_profile()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
}
|
||||
|
||||
// ---- CompactLogix ----
|
||||
|
||||
[Fact]
|
||||
public void CompactLogix_profile_uses_narrower_connection_size()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.CompactLogix;
|
||||
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
|
||||
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
|
||||
p.DefaultCipPath.ShouldBe("1,0");
|
||||
p.SupportsRequestPacking.ShouldBeTrue();
|
||||
p.SupportsConnectedMessaging.ShouldBeTrue();
|
||||
p.MaxFragmentBytes.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.10/1,0", AbCipPlcFamily.CompactLogix)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
|
||||
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
|
||||
}
|
||||
|
||||
// ---- Micro800 ----
|
||||
|
||||
[Fact]
|
||||
public void Micro800_profile_is_unconnected_only_with_empty_path()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.Micro800;
|
||||
p.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||
p.DefaultConnectionSize.ShouldBe(488);
|
||||
p.DefaultCipPath.ShouldBe(""); // no backplane routing
|
||||
p.SupportsRequestPacking.ShouldBeFalse();
|
||||
p.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
p.MaxFragmentBytes.ShouldBe(484);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState("ab://192.168.1.20/")!;
|
||||
state.ParsedAddress.CipPath.ShouldBe("");
|
||||
state.Profile.SupportsRequestPacking.ShouldBeFalse();
|
||||
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 123 } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
|
||||
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
factory.Tags["X"].CreationParams.CipPath.ShouldBe("");
|
||||
factory.Tags["X"].CreationParams.LibplctagPlcAttribute.ShouldBe("micro800");
|
||||
}
|
||||
|
||||
// ---- GuardLogix ----
|
||||
|
||||
[Fact]
|
||||
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
|
||||
{
|
||||
var p = AbCipPlcFamilyProfile.GuardLogix;
|
||||
// Wire protocol is identical to ControlLogix — only the safety-partition semantics differ,
|
||||
// which is a per-tag concern surfaced via AbCipTagDefinition.SafetyTag.
|
||||
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
||||
p.DefaultConnectionSize.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
|
||||
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||
Writable: true, SafetyTag: true),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "NormalTag").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "SafetyTag").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
|
||||
Writable: true, SafetyTag: true),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("SafetySet", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
// ---- ForFamily dispatch ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")]
|
||||
public void ForFamily_dispatches_to_correct_profile(AbCipPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbCipPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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,41 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipStatusMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
|
||||
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbCipStatusMapper.Good)]
|
||||
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
|
||||
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
|
||||
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections.Concurrent;
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipSubscriptionTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"Speed" => new FakeAbCipTag(p) { Value = 1800 },
|
||||
"Temp" => new FakeAbCipTag(p) { Value = 72.5f },
|
||||
_ => new FakeAbCipTag(p),
|
||||
};
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.FullReference).ShouldContain("Speed");
|
||||
events.Select(e => e.FullReference).ShouldContain("Temp");
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_value_raises_only_once()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1800 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await Task.Delay(500);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_between_polls_raises_OnDataChange()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 100 };
|
||||
factory.Customise = _ => tagRef;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
tagRef.Value = 200; // simulate PLC change
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
events.Last().Snapshot.Value.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
factory.Customise = _ => tagRef;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interval_below_100ms_is_floored()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(5), CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
// Value is stable → only the initial-data push fires; the 100 ms floor keeps polls sparse enough
|
||||
// that no extra event is produced against a stable value.
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName == "Motor1.Speed"
|
||||
? new FakeAbCipTag(p) { Value = 77 }
|
||||
: new FakeAbCipTag(p);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(77);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
146
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
Normal file
146
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipTagPathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_single_segment()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor1_Speed");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBeNull();
|
||||
p.Segments.Count.ShouldBe(1);
|
||||
p.Segments[0].Name.ShouldBe("Motor1_Speed");
|
||||
p.Segments[0].Subscripts.ShouldBeEmpty();
|
||||
p.BitIndex.ShouldBeNull();
|
||||
p.ToLibplctagName().ShouldBe("Motor1_Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_parses()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Single().Name.ShouldBe("StepIndex");
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_member_access_splits_segments()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]);
|
||||
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_dim_array_subscript()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Data[7]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Data");
|
||||
p.Segments.Single().Subscripts.ShouldBe([7]);
|
||||
p.ToLibplctagName().ShouldBe("Data[7]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_dim_array_subscript()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Matrix[1,2,3]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]);
|
||||
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_in_dint_captured_as_bit_index()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Flags.3");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Flags");
|
||||
p.BitIndex.ShouldBe(3);
|
||||
p.ToLibplctagName().ShouldBe("Flags.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_in_dint_after_member()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Motor.Status.12");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]);
|
||||
p.BitIndex.ShouldBe(12);
|
||||
p.ToLibplctagName().ShouldBe("Motor.Status.12");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_index_32_rejected_out_of_range()
|
||||
{
|
||||
// 32 exceeds the DINT bit width — treated as a member name rather than bit selector,
|
||||
// which fails ident validation and returns null.
|
||||
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_with_members_and_subscript_and_bit()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]);
|
||||
p.Segments[0].Subscripts.ShouldBe([0]);
|
||||
p.BitIndex.ShouldBe(5);
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Program:")] // empty scope
|
||||
[InlineData("Program:MP")] // no body after scope
|
||||
[InlineData("1InvalidStart")] // ident starts with digit
|
||||
[InlineData("Bad Name")] // space in ident
|
||||
[InlineData("Motor[]")] // empty subscript
|
||||
[InlineData("Motor[-1]")] // negative subscript
|
||||
[InlineData("Motor[a]")] // non-numeric subscript
|
||||
[InlineData("Motor[")] // unbalanced bracket
|
||||
[InlineData("Motor.")] // trailing dot
|
||||
[InlineData(".Motor")] // leading dot
|
||||
public void Invalid_shapes_return_null(string? input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ident_with_underscore_accepted()
|
||||
{
|
||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_recomposes_round_trip()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
"Motor1_Speed",
|
||||
"Program:Main.Counter",
|
||||
"Array[5]",
|
||||
"Matrix[1,2]",
|
||||
"Obj.Member.Sub",
|
||||
"Flags.0",
|
||||
"Program:P.Obj[2].Flags.15",
|
||||
};
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var parsed = AbCipTagPath.TryParse(c);
|
||||
parsed.ShouldNotBeNull(c);
|
||||
parsed.ToLibplctagName().ShouldBe(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtMemberTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UDT_with_declared_members_fans_out_to_member_variables()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
|
||||
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
|
||||
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||
variables.ShouldContain(("Speed", "Motor1.Speed"));
|
||||
variables.ShouldContain(("Running", "Motor1.Running"));
|
||||
variables.ShouldContain(("SetPoint", "Motor1.SetPoint"));
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Running").Info.SecurityClass
|
||||
.ShouldBe(SecurityClassification.ViewOnly);
|
||||
builder.Variables.Single(v => v.BrowseName == "SetPoint").Info.WriteIdempotent
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName switch
|
||||
{
|
||||
"Motor1.Speed" => new FakeAbCipTag(p) { Value = 1800 },
|
||||
"Motor1.Running" => new FakeAbCipTag(p) { Value = true },
|
||||
_ => new FakeAbCipTag(p),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Running", AbCipDataType.Bool),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor1.Speed", "Motor1.Running"], CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe(1800);
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].Value.ShouldBe(true);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_write_routes_through_synthesised_tagpath()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Motor1.SetPoint", 42.5f)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_read_write_honours_member_Writable_flag()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
|
||||
]),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Motor1.Status", 1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
|
||||
{
|
||||
// Fallback path: a Structure tag with no declared Members still appears as a Variable so
|
||||
// downstream configuration can address it manually. This matches the "black box" note in
|
||||
// AbCipTagDefinition's docstring.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "OpaqueUdt");
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_Members_list_is_treated_like_null()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "EmptyUdt");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
]),
|
||||
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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,186 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipSymbolObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build one Symbol Object entry in the byte layout
|
||||
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
|
||||
/// </summary>
|
||||
private static byte[] BuildEntry(
|
||||
uint instanceId,
|
||||
ushort symbolType,
|
||||
ushort elementLength,
|
||||
(uint, uint, uint) arrayDims,
|
||||
string name)
|
||||
{
|
||||
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||||
var nameLen = nameBytes.Length;
|
||||
var totalLen = 22 + nameLen;
|
||||
if ((totalLen & 1) != 0) totalLen++; // pad to even
|
||||
|
||||
var buf = new byte[totalLen];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
|
||||
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] chunks)
|
||||
{
|
||||
var total = chunks.Sum(c => c.Length);
|
||||
var result = new byte[total];
|
||||
var pos = 0;
|
||||
foreach (var c in chunks)
|
||||
{
|
||||
Buffer.BlockCopy(c, 0, result, pos, c.Length);
|
||||
pos += c.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 42,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Counter");
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
|
||||
tags.Count.ShouldBe(1);
|
||||
tags[0].Name.ShouldBe("Counter");
|
||||
tags[0].ProgramScope.ShouldBeNull();
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[0].IsSystemTag.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0xC1, AbCipDataType.Bool)]
|
||||
[InlineData((byte)0xC2, AbCipDataType.SInt)]
|
||||
[InlineData((byte)0xC3, AbCipDataType.Int)]
|
||||
[InlineData((byte)0xC4, AbCipDataType.DInt)]
|
||||
[InlineData((byte)0xC5, AbCipDataType.LInt)]
|
||||
[InlineData((byte)0xC6, AbCipDataType.USInt)]
|
||||
[InlineData((byte)0xC7, AbCipDataType.UInt)]
|
||||
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
|
||||
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
|
||||
[InlineData((byte)0xCA, AbCipDataType.Real)]
|
||||
[InlineData((byte)0xCB, AbCipDataType.LReal)]
|
||||
[InlineData((byte)0xD0, AbCipDataType.String)]
|
||||
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_flag_overrides_type_code_and_yields_Structure()
|
||||
{
|
||||
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 5,
|
||||
symbolType: 0x8000 | 0x0234,
|
||||
elementLength: 16,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Motor1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_flag_surfaces_as_IsSystemTag_true()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 99,
|
||||
symbolType: 0x1000 | 0xC4, // system flag + DINT
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "__Reserved_1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.IsSystemTag.ShouldBeTrue();
|
||||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_name_splits_prefix_into_ProgramScope()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 1,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Program:MainProgram.StepIndex");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.ProgramScope.ShouldBe("MainProgram");
|
||||
tag.Name.ShouldBe("StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_entries_decode_in_wire_order_with_even_padding()
|
||||
{
|
||||
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
|
||||
var bytes = Concat(
|
||||
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
|
||||
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
tags.Count.ShouldBe(2);
|
||||
tags[0].Name.ShouldBe("Abc");
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[1].Name.ShouldBe("Pi");
|
||||
tags[1].DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_buffer_stops_decoding_gracefully()
|
||||
{
|
||||
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
|
||||
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
|
||||
var truncated = full.Take(full.Length - 5).ToArray();
|
||||
|
||||
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_buffer_yields_no_tags()
|
||||
{
|
||||
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Counter", null, "Counter")]
|
||||
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
|
||||
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
|
||||
[InlineData("Program:", null, "Program:")] // malformed — no dot
|
||||
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
|
||||
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
|
||||
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
|
||||
{
|
||||
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
|
||||
scope.ShouldBe(expectedScope);
|
||||
name.ShouldBe(expectedName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipTemplateObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
|
||||
/// strings (UDT name first, then member names).
|
||||
/// </summary>
|
||||
private static byte[] BuildTemplate(
|
||||
string udtName,
|
||||
uint instanceSize,
|
||||
params (string name, ushort info, ushort arraySize, uint offset)[] members)
|
||||
{
|
||||
var memberCount = (ushort)members.Length;
|
||||
var headerSize = 12;
|
||||
var memberBlockSize = 8;
|
||||
var blocksSize = memberBlockSize * members.Length;
|
||||
|
||||
var stringsBuf = new MemoryStream();
|
||||
void AppendString(string s)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
|
||||
stringsBuf.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
AppendString(udtName);
|
||||
foreach (var m in members) AppendString(m.name);
|
||||
var strings = stringsBuf.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blocksSize + strings.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + (i * memberBlockSize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
|
||||
}
|
||||
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simple_two_member_UDT_decodes_correctly()
|
||||
{
|
||||
var bytes = BuildTemplate("MotorUdt", instanceSize: 8,
|
||||
("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0
|
||||
("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.TotalSize.ShouldBe(8);
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
shape.Members[0].Name.ShouldBe("Speed");
|
||||
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
shape.Members[0].Offset.ShouldBe(0);
|
||||
shape.Members[0].ArrayLength.ShouldBe(1);
|
||||
shape.Members[1].Name.ShouldBe("Enabled");
|
||||
shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||
shape.Members[1].Offset.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_member_flag_surfaces_Structure_type()
|
||||
{
|
||||
var bytes = BuildTemplate("ContainerUdt", instanceSize: 32,
|
||||
("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_member_carries_non_one_ArrayLength()
|
||||
{
|
||||
var bytes = BuildTemplate("ArrayUdt", instanceSize: 40,
|
||||
("Values", info: 0xC4, arraySize: 10, offset: 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().ArrayLength.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_atomic_types_preserve_offsets_and_types()
|
||||
{
|
||||
var bytes = BuildTemplate("MixedUdt", instanceSize: 24,
|
||||
("A", 0xC1, 0, 0), // BOOL
|
||||
("B", 0xC2, 0, 1), // SINT
|
||||
("C", 0xC3, 0, 2), // INT
|
||||
("D", 0xC4, 0, 4), // DINT
|
||||
("E", 0xCA, 0, 8), // REAL
|
||||
("F", 0xCB, 0, 16)); // LREAL
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(6);
|
||||
shape.Members.Select(m => m.DataType).ShouldBe(
|
||||
[AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int,
|
||||
AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]);
|
||||
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_atomic_type_code_falls_back_to_Structure()
|
||||
{
|
||||
var bytes = BuildTemplate("WeirdUdt", instanceSize: 4,
|
||||
("Unknown", info: 0xFF, 0, 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_member_count_returns_null()
|
||||
{
|
||||
var buf = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
|
||||
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_buffer_returns_null()
|
||||
{
|
||||
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_member_name_surfaces_placeholder()
|
||||
{
|
||||
// Header says 3 members but strings list has only UDT name + 2 member names.
|
||||
var memberCount = (ushort)3;
|
||||
var buf = new byte[12 + 8 * 3];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var o = 12 + i * 8;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
|
||||
}
|
||||
// strings: only UDT + 2 members, missing the third.
|
||||
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
|
||||
var combined = buf.Concat(strings).ToArray();
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(combined);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(3);
|
||||
shape.Members[2].Name.ShouldBe("<member_2>");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
|
||||
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
|
||||
[InlineData("Only;\0", new[] { "Only" })]
|
||||
[InlineData(";\0", new string[] { })] // empty
|
||||
[InlineData("", new string[] { })]
|
||||
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
67
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
Normal file
67
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fake for <see cref="IAbCipTagRuntime"/>. Stores the mock PLC value in
|
||||
/// <see cref="Value"/> + returns it from <see cref="DecodeValue"/>. Use
|
||||
/// <see cref="Status"/> to simulate libplctag error codes,
|
||||
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
|
||||
/// </summary>
|
||||
internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
{
|
||||
public AbCipTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
|
||||
|
||||
public virtual Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WriteCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
|
||||
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
||||
|
||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
|
||||
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
|
||||
{
|
||||
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
|
||||
Tags[createParams.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("N7:0", "N", 7, 0, null, null)]
|
||||
[InlineData("N7:15", "N", 7, 15, null, null)]
|
||||
[InlineData("F8:5", "F", 8, 5, null, null)]
|
||||
[InlineData("B3:0/0", "B", 3, 0, 0, null)]
|
||||
[InlineData("B3:2/7", "B", 3, 2, 7, null)]
|
||||
[InlineData("ST9:0", "ST", 9, 0, null, null)]
|
||||
[InlineData("L9:3", "L", 9, 3, null, null)]
|
||||
[InlineData("I:0/0", "I", null, 0, 0, null)]
|
||||
[InlineData("O:1/2", "O", null, 1, 2, null)]
|
||||
[InlineData("S:1", "S", null, 1, null, null)]
|
||||
[InlineData("T4:0.ACC", "T", 4, 0, null, "ACC")]
|
||||
[InlineData("T4:0.PRE", "T", 4, 0, null, "PRE")]
|
||||
[InlineData("C5:2.CU", "C", 5, 2, null, "CU")]
|
||||
[InlineData("R6:0.LEN", "R", 6, 0, null, "LEN")]
|
||||
[InlineData("N7:0/3", "N", 7, 0, 3, null)]
|
||||
public void TryParse_accepts_valid_pccc_addresses(string input, string letter, int? file, int word, int? bit, string? sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.FileNumber.ShouldBe(file);
|
||||
a.WordNumber.ShouldBe(word);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("N7")] // missing :word
|
||||
[InlineData(":0")] // missing file
|
||||
[InlineData("X7:0")] // unknown file letter
|
||||
[InlineData("N7:-1")] // negative word
|
||||
[InlineData("N7:abc")] // non-numeric word
|
||||
[InlineData("N7:0/-1")] // negative bit
|
||||
[InlineData("N7:0/32")] // bit out of range
|
||||
[InlineData("Nabc:0")] // non-numeric file number
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:0")]
|
||||
[InlineData("F8:5")]
|
||||
[InlineData("B3:0/0")]
|
||||
[InlineData("ST9:0")]
|
||||
[InlineData("T4:0.ACC")]
|
||||
[InlineData("I:0/0")]
|
||||
[InlineData("S:1")]
|
||||
public void ToLibplctagName_roundtrips(string input)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyBitRmwTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
|
||||
.ToArray();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
|
||||
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
|
||||
builder.Variables.Count.ShouldBe(2);
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = 42 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var tagRef = new FakeAbLegacyTag(
|
||||
new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = _ => tagRef };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_one_per_device()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_successful_read()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_read_failure()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0",
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_disabled_when_ProbeAddress_is_null()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
|
||||
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!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,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbLegacy()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbLegacy");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_devices_assigns_family_profiles()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/", AbLegacyPlcFamily.MicroLogix),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.7/1,0", AbLegacyPlcFamily.Plc5),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(3);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Slc500);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.MicroLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.7/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Plc5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsLongFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.DefaultCipPath.ShouldBe("");
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.LibplctagPlcAttribute.ShouldBe("plc5");
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.LibplctagPlcAttribute.ShouldBe("logixpccc");
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsLongFile.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "slc500")]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, "micrologix")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "plc5")]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, "logixpccc")]
|
||||
public void ForFamily_dispatches_correctly(AbLegacyPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_pccc_types()
|
||||
{
|
||||
AbLegacyDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbLegacyDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Long.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Float.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHostAndStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("ab://plc-slc.factory/1,2", "plc-slc.factory", 44818, "1,2")]
|
||||
public void HostAddress_parses_valid(string input, string gateway, int port, string path)
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("http://10.0.0.5/1,0")]
|
||||
[InlineData("ab://10.0.0.5")]
|
||||
[InlineData("ab:///1,0")]
|
||||
[InlineData("ab://10.0.0.5:0/1,0")]
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_canonicalises()
|
||||
{
|
||||
new AbLegacyHostAddress("10.0.0.5", 44818, "1,0").ToString().ShouldBe("ab://10.0.0.5/1,0");
|
||||
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
|
||||
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x20, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x30, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x40, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x50, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0xF0, AbLegacyStatusMapper.BadInternalError)]
|
||||
[InlineData((byte)0xFF, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
public void PcccStatus_maps_known_codes(byte sts, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(1, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(-5, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
public void LibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReadWriteTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float),
|
||||
new AbLegacyTagDefinition("C", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => p.TagName switch
|
||||
{
|
||||
"N7:0" => new FakeAbLegacyTag(p) { Value = 1 },
|
||||
"F8:0" => new FakeAbLegacyTag(p) { Value = 3.14f },
|
||||
_ => new FakeAbLegacyTag(p) { Value = "hello" },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
snapshots[0].Value.ShouldBe(1);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_TagCreateParams_composed_from_device_and_profile()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var p = factory.Tags["N7:5"].CreationParams;
|
||||
p.Gateway.ShouldBe("10.0.0.5");
|
||||
p.Port.ShouldBe(44818);
|
||||
p.CipPath.ShouldBe("1,0");
|
||||
p.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
p.TagName.ShouldBe("N7:5");
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejects_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("RO", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_N_file_write_encodes_and_flushes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 123)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["N7:0"].Value.ShouldBe(123);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_within_word_write_now_succeeds_via_RMW()
|
||||
{
|
||||
// Task #181 pass 2 lifted this gap — N-file bit writes now go through
|
||||
// WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Bit3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ThrowOnWrite = true };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: false),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("Unknown", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_runtimes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
|
||||
await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbLegacyDataType.Bit && bitIndex is not null)
|
||||
throw new NotSupportedException("bit-within-word RMW deferred");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
{
|
||||
public AbLegacyTagCreateParams CreationParams { get; }
|
||||
public object? Value { get; set; }
|
||||
public int Status { get; set; }
|
||||
public bool ThrowOnInitialize { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int InitializeCount { get; private set; }
|
||||
public int ReadCount { get; private set; }
|
||||
public int WriteCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p;
|
||||
|
||||
public virtual Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
InitializeCount++;
|
||||
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
WriteCount++;
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p);
|
||||
Tags[p.TagName] = fake;
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
internal class FakeFocasClient : IFocasClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int ConnectCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
public bool ThrowOnConnect { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public bool ProbeResult { get; set; } = true;
|
||||
public Exception? Exception { get; set; }
|
||||
|
||||
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
||||
|
||||
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
var key = address.Canonical;
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((address, type, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public List<FakeFocasClient> Clients { get; } = new();
|
||||
public Func<FakeFocasClient>? Customise { get; set; }
|
||||
|
||||
public IFocasClient Create()
|
||||
{
|
||||
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
||||
Clients.Add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 FocasCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
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 == "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);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe((sbyte)42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_success()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_failure()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193");
|
||||
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!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,123 @@
|
||||
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 FocasPmcBitRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
|
||||
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
|
||||
/// the current bit; WriteAsync stores the full byte the driver issues.
|
||||
/// </summary>
|
||||
private sealed class PmcRmwFake : FakeFocasClient
|
||||
{
|
||||
public byte[] PmcBytes { get; } = new byte[1024];
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
|
||||
return base.ReadAsync(address, type, ct);
|
||||
}
|
||||
|
||||
public override Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
|
||||
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
{
|
||||
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var current = PmcBytes[address.Number];
|
||||
PmcBytes[address.Number] = Convert.ToBoolean(value)
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
return base.WriteAsync(address, type, value, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var fake = new PmcRmwFake();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0b0000_0001;
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0xFF;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0;
|
||||
|
||||
for (var b = 0; b < 8; b++)
|
||||
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_to_different_bytes_does_not_contend()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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 FocasReadWriteTests
|
||||
{
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_PMC_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(1500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_connection()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FOCAS_error_status_maps_via_status_mapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_across_areas()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)5,
|
||||
["PARAM:1820"] = 1500,
|
||||
["MACRO:500"] = 2.718,
|
||||
},
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe((sbyte)5);
|
||||
snapshots[1].Value.ShouldBe(1500);
|
||||
snapshots[2].Value.ShouldBe(2.718);
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_write_logs_address_type_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var write = factory.Clients[0].WriteLog.Single();
|
||||
write.addr.Canonical.ShouldBe("R100");
|
||||
write.type.ShouldBe(FocasDataType.Int16);
|
||||
write.value.ShouldBe((short)1800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
|
||||
return c;
|
||||
};
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", (sbyte)1),
|
||||
new WriteRequest("B", (sbyte)2),
|
||||
new WriteRequest("Unknown", (sbyte)3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_client()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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 FocasScaffoldingTests
|
||||
{
|
||||
// ---- FocasHostAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
|
||||
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
|
||||
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
|
||||
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
|
||||
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
|
||||
public void HostAddress_parses_valid(string input, string host, int port)
|
||||
{
|
||||
var parsed = FocasHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Host.ShouldBe(host);
|
||||
parsed.Port.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://10.0.0.5/")]
|
||||
[InlineData("focas:10.0.0.5:8193")] // missing //
|
||||
[InlineData("focas://")] // empty body
|
||||
[InlineData("focas://10.0.0.5:0")] // port 0
|
||||
[InlineData("focas://10.0.0.5:65536")] // port out of range
|
||||
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
FocasHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_strips_default_port()
|
||||
{
|
||||
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
|
||||
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
|
||||
}
|
||||
|
||||
// ---- FocasAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
|
||||
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
|
||||
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
|
||||
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
|
||||
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
|
||||
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
|
||||
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
|
||||
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
|
||||
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
|
||||
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
|
||||
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
|
||||
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
|
||||
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBe(letter);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
|
||||
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
|
||||
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
|
||||
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBeNull();
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
|
||||
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
|
||||
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown PMC letter
|
||||
[InlineData("X")] // missing number
|
||||
[InlineData("X-1")] // negative number
|
||||
[InlineData("Xabc")] // non-numeric
|
||||
[InlineData("X0.8")] // bit out of range (0-7)
|
||||
[InlineData("X0.-1")] // negative bit
|
||||
[InlineData("PARAM:")] // missing number
|
||||
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
|
||||
[InlineData("MACRO:abc")] // non-numeric
|
||||
public void Address_rejects_invalid_forms(string? input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("R100")]
|
||||
[InlineData("F20.3")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:100")]
|
||||
public void Address_Canonical_roundtrips(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- FocasDataType ----
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_focas_types()
|
||||
{
|
||||
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ---- FocasStatusMapper ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, FocasStatusMapper.Good)]
|
||||
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
|
||||
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
|
||||
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
|
||||
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
|
||||
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
|
||||
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
|
||||
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
|
||||
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
|
||||
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
|
||||
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
|
||||
{
|
||||
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- FocasDriver ----
|
||||
|
||||
[Fact]
|
||||
public void DriverType_is_FOCAS()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("FOCAS");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_parses_device_addresses()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
|
||||
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("not-an-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Default_factory_throws_on_Create_with_deployment_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("Fwlib32.dll");
|
||||
ex.Message.ShouldContain("licensed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
|
||||
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
|
||||
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FwlibNativeHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("G", 0)]
|
||||
[InlineData("F", 1)]
|
||||
[InlineData("Y", 2)]
|
||||
[InlineData("X", 3)]
|
||||
[InlineData("A", 4)]
|
||||
[InlineData("R", 5)]
|
||||
[InlineData("T", 6)]
|
||||
[InlineData("K", 7)]
|
||||
[InlineData("C", 8)]
|
||||
[InlineData("D", 9)]
|
||||
[InlineData("E", 10)]
|
||||
[InlineData("g", 0)] // case-insensitive
|
||||
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z")]
|
||||
[InlineData("")]
|
||||
[InlineData("XX")]
|
||||
public void PmcAddrType_rejects_unknown_letters(string letter)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasDataType.Bit, 0)] // byte
|
||||
[InlineData(FocasDataType.Byte, 0)]
|
||||
[InlineData(FocasDataType.Int16, 1)] // word
|
||||
[InlineData(FocasDataType.Int32, 2)] // long
|
||||
[InlineData(FocasDataType.Float32, 4)]
|
||||
[InlineData(FocasDataType.Float64, 5)]
|
||||
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
|
||||
{
|
||||
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
|
||||
((sbyte)buf[0]).ShouldBe((sbyte)-5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int16_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x34);
|
||||
buf[1].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x78);
|
||||
buf[1].ShouldBe((byte)0x56);
|
||||
buf[2].ShouldBe((byte)0x34);
|
||||
buf[3].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
|
||||
{
|
||||
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
|
||||
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
|
||||
// no-bit-index case (treated as a whole-byte boolean).
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)1);
|
||||
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeParamValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[32];
|
||||
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
|
||||
buf[0].ShouldBe((byte)0x0D);
|
||||
buf[1].ShouldBe((byte)0x0C);
|
||||
buf[2].ShouldBe((byte)0x0B);
|
||||
buf[3].ShouldBe((byte)0x0A);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusBitRmwTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusBitRmwTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusBitRmwTests
|
||||
{
|
||||
/// <summary>Fake transport capturing each PDU so tests can assert on the read + write sequence.</summary>
|
||||
private sealed class RmwTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public readonly List<byte[]> Pdus = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Pdus.Add(pdu);
|
||||
if (pdu[0] == 0x03)
|
||||
{
|
||||
// FC03 Read Holding Registers.
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (pdu[0] == 0x06)
|
||||
{
|
||||
// FC06 Write Single Register.
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var v = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
HoldingRegisters[addr] = v;
|
||||
return Task.FromResult(new byte[] { 0x06, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, RmwTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new RmwTransport();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_current_register_ORs_bit_writes_back()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 0b0000_0001; // bit 0 already set
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0u);
|
||||
fake.HoldingRegisters[10].ShouldBe((ushort)0b0000_1001); // bit 3 now set, bit 0 preserved
|
||||
// Two PDUs: FC03 read then FC06 write.
|
||||
fake.Pdus.Count.ShouldBe(2);
|
||||
fake.Pdus[0][0].ShouldBe((byte)0x03);
|
||||
fake.Pdus[1][0].ShouldBe((byte)0x06);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 0xFFFF; // all bits set
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag3", false)], CancellationToken.None);
|
||||
|
||||
fake.HoldingRegisters[10].ShouldBe((ushort)0b1111_1111_1111_0111); // bit 3 cleared, rest preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates()
|
||||
{
|
||||
// Serialization test — 8 writers target different bits in register 20. Without the RMW
|
||||
// lock, concurrent reads interleave + last-to-commit wins so some bits get lost.
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new ModbusTagDefinition($"Bit{b}", ModbusRegion.HoldingRegisters, 20, ModbusDataType.BitInRegister, BitIndex: (byte)b))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[20] = 0;
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)0xFF); // all 8 bits set
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new ModbusTagDefinition($"Bit{i}", ModbusRegion.HoldingRegisters, (ushort)(50 + i), ModbusDataType.BitInRegister, BitIndex: 0))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.HoldingRegisters[50 + i].ShouldBe((ushort)0x01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_preserves_other_bits_in_the_same_register()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("BitA", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 5),
|
||||
new ModbusTagDefinition("BitB", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 10));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("BitA", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("BitB", true)], CancellationToken.None);
|
||||
|
||||
fake.HoldingRegisters[30].ShouldBe((ushort)((1 << 5) | (1 << 10)));
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,15 @@ public sealed class ModbusDataTypeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BitInRegister_write_is_not_supported_in_PR24()
|
||||
public void BitInRegister_EncodeRegister_still_rejects_direct_calls()
|
||||
{
|
||||
// BitInRegister writes now go through WriteBitInRegisterAsync's RMW path (task #181).
|
||||
// EncodeRegister should never be reached for this type — if it is, throwing keeps an
|
||||
// unintended caller loud rather than silently clobbering the register.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: 5);
|
||||
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
|
||||
.Message.ShouldContain("read-modify-write");
|
||||
.Message.ShouldContain("WriteBitInRegisterAsync");
|
||||
}
|
||||
|
||||
// --- String ---
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
internal class FakeTwinCATClient : ITwinCATClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int ConnectCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
public bool ThrowOnConnect { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public bool ThrowOnProbe { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||
public bool ProbeResult { get; set; } = true;
|
||||
|
||||
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteValueAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((symbolPath, type, bitIndex, value));
|
||||
Values[symbolPath] = value;
|
||||
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnProbe) return Task.FromResult(false);
|
||||
return Task.FromResult(ProbeResult);
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
IsConnected = false;
|
||||
}
|
||||
|
||||
// ---- notification fake ----
|
||||
|
||||
public List<FakeNotification> Notifications { get; } = new();
|
||||
public bool ThrowOnAddNotification { get; set; }
|
||||
|
||||
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
||||
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||
Notifications.Add(reg);
|
||||
return Task.FromResult<ITwinCATNotificationHandle>(reg);
|
||||
}
|
||||
|
||||
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
|
||||
public void FireNotification(string symbolPath, object? value)
|
||||
{
|
||||
foreach (var n in Notifications)
|
||||
if (!n.Disposed && string.Equals(n.SymbolPath, symbolPath, StringComparison.OrdinalIgnoreCase))
|
||||
n.OnChange(symbolPath, value);
|
||||
}
|
||||
|
||||
// ---- symbol browser fake ----
|
||||
|
||||
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure");
|
||||
await Task.CompletedTask;
|
||||
foreach (var sym in BrowseResults)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
yield return sym;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeNotification(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex,
|
||||
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
||||
{
|
||||
public string SymbolPath { get; } = symbolPath;
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
owner.Notifications.Remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
|
||||
{
|
||||
public List<FakeTwinCATClient> Clients { get; } = new();
|
||||
public Func<FakeTwinCATClient>? Customise { get; set; }
|
||||
|
||||
public ITwinCATClient Create()
|
||||
{
|
||||
var client = Customise?.Invoke() ?? new FakeTwinCATClient();
|
||||
Clients.Add(client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATAmsAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ads://5.23.91.23.1.1:851", "5.23.91.23.1.1", 851)]
|
||||
[InlineData("ads://5.23.91.23.1.1:852", "5.23.91.23.1.1", 852)]
|
||||
[InlineData("ads://5.23.91.23.1.1", "5.23.91.23.1.1", 851)] // default port
|
||||
[InlineData("ads://127.0.0.1.1.1:851", "127.0.0.1.1.1", 851)]
|
||||
[InlineData("ADS://5.23.91.23.1.1:851", "5.23.91.23.1.1", 851)] // case-insensitive scheme
|
||||
[InlineData("ads://10.0.0.1.1.1:10000", "10.0.0.1.1.1", 10000)] // system service port
|
||||
public void TryParse_accepts_valid_ams_addresses(string input, string netId, int port)
|
||||
{
|
||||
var parsed = TwinCATAmsAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.NetId.ShouldBe(netId);
|
||||
parsed.Port.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("tcp://5.23.91.23.1.1:851")] // wrong scheme
|
||||
[InlineData("ads:5.23.91.23.1.1:851")] // missing //
|
||||
[InlineData("ads://")] // empty body
|
||||
[InlineData("ads://5.23.91.23.1:851")] // only 5 octets
|
||||
[InlineData("ads://5.23.91.23.1.1.1:851")] // 7 octets
|
||||
[InlineData("ads://5.23.91.256.1.1:851")] // octet > 255
|
||||
[InlineData("ads://5.23.91.23.1.1:0")] // port 0
|
||||
[InlineData("ads://5.23.91.23.1.1:65536")] // port out of range
|
||||
[InlineData("ads://5.23.91.23.1.1:abc")] // non-numeric port
|
||||
[InlineData("ads://a.b.c.d.e.f:851")] // non-numeric octets
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
TwinCATAmsAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5.23.91.23.1.1", 851, "ads://5.23.91.23.1.1")] // default port stripped
|
||||
[InlineData("5.23.91.23.1.1", 852, "ads://5.23.91.23.1.1:852")]
|
||||
public void ToString_canonicalises(string netId, int port, string expected)
|
||||
{
|
||||
new TwinCATAmsAddress(netId, port).ToString().ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_is_stable()
|
||||
{
|
||||
const string input = "ads://5.23.91.23.1.1:852";
|
||||
var parsed = TwinCATAmsAddress.TryParse(input)!;
|
||||
TwinCATAmsAddress.TryParse(parsed.ToString()).ShouldBe(parsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "TwinCAT");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false, // poll-mode test
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false, // poll-mode test
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
||||
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_successful_probe()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_probe_failure()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_disabled_when_Enabled_is_false()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
||||
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("ads://5.23.91.23.1.1:851");
|
||||
drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!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,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_TwinCAT()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("TwinCAT");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_parses_device_addresses()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
||||
new TwinCATDeviceOptions("ads://10.0.0.1.1.1:852", DeviceName: "Machine2"),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("ads://5.23.91.23.1.1:851")!.ParsedAddress.Port.ShouldBe(851);
|
||||
drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("not-an-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_cycles_devices()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_iec_types()
|
||||
{
|
||||
TwinCATDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
TwinCATDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
TwinCATDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, TwinCATStatusMapper.Good)]
|
||||
[InlineData(1798u, TwinCATStatusMapper.BadNodeIdUnknown)] // symbol not found
|
||||
[InlineData(1808u, TwinCATStatusMapper.BadNotWritable)] // access denied
|
||||
[InlineData(1861u, TwinCATStatusMapper.BadTimeout)] // sync timeout
|
||||
[InlineData(1793u, TwinCATStatusMapper.BadOutOfRange)] // invalid index group
|
||||
[InlineData(1794u, TwinCATStatusMapper.BadOutOfRange)] // invalid index offset
|
||||
[InlineData(1792u, TwinCATStatusMapper.BadNotSupported)] // service not supported
|
||||
[InlineData(7u, TwinCATStatusMapper.BadCommunicationError)] // port unreachable
|
||||
[InlineData(99999u, TwinCATStatusMapper.BadCommunicationError)] // unknown → generic comm fail
|
||||
public void StatusMapper_covers_known_ads_error_codes(uint adsError, uint expected)
|
||||
{
|
||||
TwinCATStatusMapper.MapAdsError(adsError).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATNativeNotificationTests
|
||||
{
|
||||
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = tags,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_registers_one_notification_per_tag()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
handle.DiagnosticId.ShouldStartWith("twincat-native-sub-");
|
||||
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(2);
|
||||
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_notification_fires_OnDataChange_with_pushed_value()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
factory.Clients[0].FireNotification("MAIN.Speed", 4200);
|
||||
factory.Clients[0].FireNotification("MAIN.Speed", 4201);
|
||||
|
||||
events.Count.ShouldBe(2);
|
||||
events.Last().Snapshot.Value.ShouldBe(4201);
|
||||
events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_unsubscribe_disposes_all_notifications()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(2);
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_unsubscribe_halts_future_notifications()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
factory.Clients[0].FireNotification("MAIN.X", 1);
|
||||
var snapshotFake = factory.Clients[0];
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
// After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing
|
||||
// to invoke. This mirrors the production contract — disposed handles no longer deliver.
|
||||
snapshotFake.FireNotification("MAIN.X", 999);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
|
||||
{
|
||||
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
|
||||
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
|
||||
// notification lingers.
|
||||
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
|
||||
|
||||
// First registration succeeded then got torn down by the catch; second threw.
|
||||
fake.AddCallCount.ShouldBe(2);
|
||||
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
|
||||
}
|
||||
|
||||
private sealed class AbTagParamsIrrelevant { }
|
||||
|
||||
private sealed class FailAfterNAddsFake : FakeTwinCATClient
|
||||
{
|
||||
private readonly int _succeedBefore;
|
||||
public int AddCallCount { get; private set; }
|
||||
|
||||
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
|
||||
{
|
||||
_succeedBefore = succeedBefore;
|
||||
}
|
||||
|
||||
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (AddCallCount > _succeedBefore)
|
||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_shutdown_disposes_subscriptions()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poll_path_still_works_when_UseNativeNotifications_false()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(150), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe(7);
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
|
||||
{
|
||||
var (drvNative, _) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drvNative.InitializeAsync("{}", CancellationToken.None);
|
||||
var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
nativeHandle.DiagnosticId.ShouldContain("native");
|
||||
|
||||
var factoryPoll = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
|
||||
};
|
||||
var drvPoll = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = false,
|
||||
}, "drv-1", factoryPoll);
|
||||
await drvPoll.InitializeAsync("{}", CancellationToken.None);
|
||||
var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
pollHandle.DiagnosticId.ShouldNotContain("native");
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATReadWriteTests
|
||||
{
|
||||
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = tags,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_DInt_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4200 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(4200);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
factory.Clients[0].IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_connection()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "GVL.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// One client, one connect — subsequent calls reuse the connected client.
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_with_ADS_error_maps_via_status_mapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("Ghost", "ads://5.23.91.23.1.1:851", "MAIN.Missing", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.ReadStatuses["MAIN.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { ThrowOnConnect = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real),
|
||||
new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient
|
||||
{
|
||||
Values =
|
||||
{
|
||||
["MAIN.A"] = 1,
|
||||
["MAIN.B"] = 3.14f,
|
||||
["MAIN.C"] = "hello",
|
||||
},
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(1);
|
||||
snapshots[1].Value.ShouldBe(3.14f);
|
||||
snapshots[2].Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new TwinCATTagDefinition("RO", "ads://5.23.91.23.1.1:851", "MAIN.RO", TwinCATDataType.DInt, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_write_logs_symbol_type_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", 4200)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
var write = factory.Clients[0].WriteLog.Single();
|
||||
write.symbol.ShouldBe("MAIN.Speed");
|
||||
write.type.ShouldBe(TwinCATDataType.DInt);
|
||||
write.value.ShouldBe(4200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_with_ADS_error_surfaces_mapped_status()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.WriteStatuses["MAIN.X"] = TwinCATStatusMapper.BadNotWritable;
|
||||
return c;
|
||||
};
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { ThrowOnWrite = true };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("Unknown", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_client()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATSymbolBrowserTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Hidden", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["Declared"]);
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, ReadOnly: false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("GVL.Setpoint", TwinCATDataType.Real, ReadOnly: false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("MAIN.Counter");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_filters_system_symbols()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("TwinCAT_SystemInfoVarList._AppInfo", TwinCATDataType.DInt, false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Constants.PI", TwinCATDataType.LReal, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Mc_InternalState", TwinCATDataType.DInt, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("__CompilerGen", TwinCATDataType.DInt, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Real", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_skips_symbols_with_null_datatype()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Struct", DataType: null, ReadOnly: false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnly_symbol_surfaces_ViewOnly()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Status", TwinCATDataType.DInt, ReadOnly: true));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_failure_is_non_fatal_predeclared_still_emits()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ThrowOnBrowse = true },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)]
|
||||
[InlineData("TwinCAT_RuntimeInfo.Something", true)]
|
||||
[InlineData("Constants.PI", true)]
|
||||
[InlineData("Mc_AxisState", true)]
|
||||
[InlineData("__hidden", true)]
|
||||
[InlineData("Global_Version", true)]
|
||||
[InlineData("MAIN.UserVar", false)]
|
||||
[InlineData("GVL.Counter", false)]
|
||||
[InlineData("MyFbInstance.State", false)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
public void SystemSymbolFilter_matches_expected_patterns(string path, bool expected)
|
||||
{
|
||||
TwinCATSystemSymbolFilter.IsSystemSymbol(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
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,138 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATSymbolPathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Single_segment_global_variable_parses()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("Counter");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Counter");
|
||||
p.ToAdsSymbolName().ShouldBe("Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void POU_dot_variable_parses()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("MAIN.bStart");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "bStart"]);
|
||||
p.ToAdsSymbolName().ShouldBe("MAIN.bStart");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GVL_reference_parses()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("GVL.Counter");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Counter"]);
|
||||
p.ToAdsSymbolName().ShouldBe("GVL.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_member_access_splits()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("Motor1.Status.Running");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Status", "Running"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_subscript_parses()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("Data[5]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Subscripts.ShouldBe([5]);
|
||||
p.ToAdsSymbolName().ShouldBe("Data[5]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_dim_array_subscript_parses()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("Matrix[1,2]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Subscripts.ShouldBe([1, 2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_access_captured_as_bit_index()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("Flags.3");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Flags");
|
||||
p.BitIndex.ShouldBe(3);
|
||||
p.ToAdsSymbolName().ShouldBe("Flags.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bit_access_after_member_path()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("GVL.Status.7");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["GVL", "Status"]);
|
||||
p.BitIndex.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combined_scope_member_subscript_bit()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse("MAIN.Motors[0].Status.5");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["MAIN", "Motors", "Status"]);
|
||||
p.Segments[1].Subscripts.ShouldBe([0]);
|
||||
p.BitIndex.ShouldBe(5);
|
||||
p.ToAdsSymbolName().ShouldBe("MAIN.Motors[0].Status.5");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(".Motor")] // leading dot
|
||||
[InlineData("Motor.")] // trailing dot
|
||||
[InlineData("Motor.[0]")] // empty segment
|
||||
[InlineData("1bad")] // ident starts with digit
|
||||
[InlineData("Bad Name")] // space in ident
|
||||
[InlineData("Motor[]")] // empty subscript
|
||||
[InlineData("Motor[-1]")] // negative subscript
|
||||
[InlineData("Motor[a]")] // non-numeric subscript
|
||||
[InlineData("Motor[")] // unbalanced bracket
|
||||
[InlineData("Flags.32")] // bit out of range (treated as ident → invalid shape)
|
||||
public void Invalid_shapes_return_null(string? input)
|
||||
{
|
||||
TwinCATSymbolPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Underscore_prefix_idents_accepted()
|
||||
{
|
||||
TwinCATSymbolPath.TryParse("_internal_var")!.Segments.Single().Name.ShouldBe("_internal_var");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAdsSymbolName_roundtrips()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
"Counter",
|
||||
"MAIN.bStart",
|
||||
"GVL.Counter",
|
||||
"Motor1.Status.Running",
|
||||
"Data[5]",
|
||||
"Matrix[1,2]",
|
||||
"Flags.3",
|
||||
"MAIN.Motors[0].Status.5",
|
||||
};
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var parsed = TwinCATSymbolPath.TryParse(c);
|
||||
parsed.ShouldNotBeNull(c);
|
||||
parsed.ToAdsSymbolName().ShouldBe(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user