Compare commits
2 Commits
ablegacy-p
...
ablegacy-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2424a0616 | ||
| 59c99190c6 |
@@ -8,18 +8,22 @@ 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, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbLegacyDriverOptions _options;
|
private readonly AbLegacyDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly IAbLegacyTagFactory _tagFactory;
|
||||||
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 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DriverInstanceId => _driverInstanceId;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
@@ -38,6 +42,7 @@ 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;
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -56,7 +61,9 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
|
|||||||
|
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
foreach (var state in _devices.Values) 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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -69,6 +76,153 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +234,13 @@ 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 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,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