Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
400fc6242c AB Legacy PR 3 — ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver. Fills out the AbLegacy capability surface — the driver now implements the same 7-interface set as AbCip (IDriver + IReadable + IWritable + ITagDiscovery + ISubscribable + IHostConnectivityProbe + IPerCallHostResolver). ITagDiscovery emits pre-declared tags under an AbLegacy root folder with a per-device sub-folder keyed on HostAddress (DeviceName fallback to HostAddress when null). Writable tags surface as SecurityClassification.Operate, non-writable as ViewOnly. No controller-side enumeration — PCCC has no @tags equivalent on SLC / MicroLogix / PLC-5 (symbol table isn't exposed the way Logix exposes it), so the pre-declared path is the only discovery mechanism. ISubscribable consumes the shared PollGroupEngine extracted in AB CIP PR 1 — reader delegate points at ReadAsync (already handles lazy runtime init + caching), onChange bridges into the driver's OnDataChange event. 100ms interval floor. Initial-data push on first poll. Makes AbLegacy the third consumer of PollGroupEngine (after Modbus and AbCip). IHostConnectivityProbe — per-device probe loop when ProbeOptions.Enabled + ProbeAddress configured (defaults to S:0 status file word 0). Lazy-init on first tick, re-init on wire failure (destroyed native handle gets recreated rather than silently staying broken). Success transitions device to Running, exception to Stopped, same-state spurious event guard under per-device lock. GetHostStatuses returns one entry per device with current state + last-change timestamp for Admin /hosts surfacing. IPerCallHostResolver maps tag full-ref → DeviceHostAddress for the Phase 6.1 (DriverInstanceId, ResolvedHostName) bulkhead/breaker keying per plan decision #144. Unknown refs fall back to first device's address (invoker handles at capability level as BadNodeIdUnknown); no devices → DriverInstanceId. ShutdownAsync cancels + disposes each probe CTS, disposes PollGroupEngine cancelling active subscriptions, disposes every cached runtime. DeviceState gains ProbeLock / HostState / HostStateChangedUtc / ProbeCts / ProbeInitialized matching AbCip's DeviceState shape. 10 new unit tests in AbLegacyCapabilityTests covering — pre-declared tags emit under AbLegacy/device folder with correct SecurityClassification, subscription initial poll raises OnDataChange with correct value, unsubscribe halts polling (value change post-unsub produces no further events), GetHostStatuses returns one entry per device, probe Running transition on successful read, probe Stopped transition on read exception, probe disabled when ProbeAddress null, ResolveHost returns declared device for known tag, falls back to first device for unknown, falls back to DriverInstanceId when no devices. Total AbLegacy unit tests now 92/92 passing (+10 from PR 2's 82); full solution builds 0 errors; AbCip + Modbus + other drivers untouched. AB Legacy driver now complete end-to-end — SLC 500 / MicroLogix / PLC-5 / LogixPccc all shippable with read / write / discovery / subscribe / probe / host-resolve, feature-parity with AbCip minus IAlarmSource (same deferral per plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:02:52 -04:00
4438fdd7b1 Merge pull request (#118) - AbLegacy R/W 2026-04-19 18:00:27 -04:00
Joseph Doherty
b2424a0616 AB Legacy PR 2 — IReadable + IWritable. IAbLegacyTagRuntime + IAbLegacyTagFactory abstraction mirrors IAbCipTagRuntime from AbCip PR 3. LibplctagLegacyTagRuntime default implementation wraps libplctag.Tag with Protocol=ab_eip + PlcType dispatched from the profile's libplctag attribute (Slc500/MicroLogix/Plc5/LogixPccc) — libplctag routes PCCC-over-EIP internally based on PlcType, so our layer just forwards the atomic type to Get/Set calls. DecodeValue handles Bit (GetBit when bitIndex is set, else GetInt8!=0), Int/AnalogInt (GetInt16 widened to int), Long (GetInt32), Float (GetFloat32), String (GetString), TimerElement/CounterElement/ControlElement (GetInt32 — sub-element selection is in the libplctag tag name like T4:0.ACC, PLC-side decode picks the right slot). EncodeValue handles the same types; bit-within-word writes throw NotSupportedException pointing at follow-up task #181 (same read-modify-write gap as Modbus BitInRegister). AbLegacyDriver implements IReadable + IWritable with the exact same shape as AbCip PR 3-4 — per-tag lazy runtime init via EnsureTagRuntimeAsync cached in DeviceState.Runtimes dict, ordered-snapshot results, health surface updates. Exception table — OperationCanceledException rethrows, NotSupportedException → BadNotSupported, FormatException/InvalidCastException → BadTypeMismatch (guard pattern C# 11 syntax), OverflowException → BadOutOfRange, anything else → BadCommunicationError. ShutdownAsync disposes every cached runtime so the native tag handles get released. 14 new unit tests in AbLegacyReadWriteTests covering unknown ref → BadNodeIdUnknown, successful N-file read with Good status + captured value, repeat-read reuses cached runtime (init count 1 across 2 reads), libplctag non-zero status mapping (-14 → BadNodeIdUnknown), read exception → BadCommunicationError + Degraded health, batched reads preserve order across N/F/ST types, TagCreateParams composition (gateway/port/path/slc500 attribute/tag-name), non-writable tag → BadNotWritable, successful write encodes + flushes, bit-within-word → BadNotSupported (RmwThrowingFake mirrors LibplctagLegacyTagRuntime's runtime check), write exception → BadCommunicationError, batch preserves order across success+fail+unknown, cancellation propagates, ShutdownAsync disposes runtimes. Total AbLegacy unit tests now 82/82 passing (+14 from PR 1's 68). Full solution builds 0 errors; Modbus + AbCip + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:58:38 -04:00
59c99190c6 Merge pull request (#117) - AbLegacy scaffolding 2026-04-19 17:56:15 -04:00
6 changed files with 999 additions and 4 deletions

View File

@@ -8,18 +8,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <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, IDisposable, IAsyncDisposable
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 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);
_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;
@@ -38,6 +51,18 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
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)
@@ -54,11 +79,19 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
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();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
@@ -69,6 +102,264 @@ public sealed class AbLegacyDriver : IDriver, IDisposable, IAsyncDisposable
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 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 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 AbLegacyDeviceOptions Options { get; } = options;
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();
}
}
}

View 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);

View File

@@ -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);
}

View File

@@ -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) { } }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}