Compare commits
4 Commits
ablegacy-p
...
ablegacy-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400fc6242c | ||
| 4438fdd7b1 | |||
|
|
b2424a0616 | ||
| 59c99190c6 |
@@ -8,18 +8,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|||||||
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
|
||||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
|
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbLegacyDriverOptions _options;
|
private readonly AbLegacyDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
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, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId)
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
|
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||||
|
IAbLegacyTagFactory? tagFactory = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_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 DriverInstanceId => _driverInstanceId;
|
||||||
@@ -38,6 +51,18 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
|||||||
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
_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);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -54,11 +79,19 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
|||||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
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();
|
_devices.Clear();
|
||||||
|
_tagsByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public DriverHealth GetHealth() => _health;
|
public DriverHealth GetHealth() => _health;
|
||||||
@@ -69,6 +102,264 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
|||||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
_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 runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||||
|
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -80,5 +371,19 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
|||||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||||
public AbLegacyDeviceOptions Options { get; } = options;
|
public AbLegacyDeviceOptions Options { get; } = options;
|
||||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
||||||
|
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,97 @@
|
|||||||
|
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)
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"Bit-within-word writes require read-modify-write; tracked in task #181.");
|
||||||
|
_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,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,256 @@
|
|||||||
|
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_rejected_as_BadNotSupported()
|
||||||
|
{
|
||||||
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) };
|
||||||
|
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.BadNotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user