AB Legacy PR 1 — Scaffolding + Core (AbLegacyDriver + PCCC address parser). New Driver.AbLegacy project with the libplctag 1.5.2 reference + the same Core.Abstractions-only project shape AbCip uses. AbLegacyHostAddress duplicates the ab://gateway[:port]/cip-path parser from AbCip since PCCC-over-EIP uses the same gateway routing convention (SLC 500 direct-wired with empty path, PLC-5 bridged through a ControlLogix chassis with full CIP path). Parser is 30 lines; copy was cheaper than introducing a shared Ab* project just to avoid duplication. AbLegacyAddress handles PCCC file addressing — file-letter + optional file-number + colon + word-number + optional sub-element (.ACC / .PRE / .EN / .DN / .CU / .CD / .LEN / .POS / .ER) + optional /bit-index. Handles the full shape variety — N7:0 (integer file 7 word 0), F8:5 (float file 8 word 5), B3:0/0 (bit file 3 word 0 bit 0), ST9:0 (string file 9 string 0), L9:3 (long file SLC 5/05+), T4:0.ACC (timer accumulator), C5:2.CU (counter count-up bit), R6:0.LEN (control length), I:0/0 (input file bit — no file number for I/O/S), O:1/2 (output file bit), S:1 (status file word), N7:0/3 (bit within integer file). Validates file letters against the canonical SLC/ML/PLC-5 set (N/F/B/L/ST/T/C/R/I/O/S/A). ToLibplctagName roundtrips so the parsed value can be handed straight to libplctag's name= attribute. AbLegacyDataType — Bit / Int (N-file, 16-bit signed) / Long (L-file, 32-bit, SLC 5/05+ only) / Float (F-file, 32-bit IEEE-754) / AnalogInt (A-file) / String (ST-file, 82-byte fixed + length word) / TimerElement / CounterElement / ControlElement. ToDriverDataType widens Long to Int32 matching the Modbus/AbCip Int64-gap convention. AbLegacyStatusMapper shares the OPC UA status constants with AbCip (same numeric values, different namespace). MapLibplctagStatus mirrors AbCip — 0 success, positive pending, negative error code families. MapPcccStatus handles PCCC STS bytes — 0x00 success, 0x10 illegal command, 0x20 bad address, 0x30 protected, 0x40/0x50 busy, 0xF0 extended status. AbLegacyDriverOptions + AbLegacyDeviceOptions + AbLegacyTagDefinition + AbLegacyProbeOptions mirror AbCip shapes — one instance supports N devices via Devices list, Tags list references devices by HostAddress cross-key, Probe uses S:0 by default as the cheap probe address. AbLegacyPlcFamilyProfile for four families — Slc500 (slc500 attribute, 1,0 default path, supports L + ST files, 240B max PCCC packet), MicroLogix (micrologix attribute, empty path for direct EIP, supports ST but not L), Plc5 (plc5 attribute, 1,0 default path, supports ST but predates L), LogixPccc (logixpccc attribute, full Logix ConnectionSize + L file support via the PCCC compatibility layer on ControlLogix). AbLegacyDriver implements IDriver only — InitializeAsync parses each device's HostAddress and selects its profile (fails fast on malformed strings → Faulted health), per-device state with parsed address + options + profile + empty placeholder for PRs 2-3. ShutdownAsync clears the device dict. 68 new unit tests across 3 files — AbLegacyAddressTests (15 valid shapes + 10 invalid shapes + 7 ToLibplctagName roundtrip), AbLegacyHostAndStatusTests (4 valid host + 5 invalid host + 8 PCCC STS + 7 libplctag status), AbLegacyDriverTests (IDriver lifecycle + multi-device init with per-family profile selection + malformed-address fault + shutdown + family profile defaults + ForFamily theory + data-type mapping). Total project count 29 src + 18 tests; full solution builds 0 errors; Modbus + AbCip + other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("N7:0", "N", 7, 0, null, null)]
|
||||
[InlineData("N7:15", "N", 7, 15, null, null)]
|
||||
[InlineData("F8:5", "F", 8, 5, null, null)]
|
||||
[InlineData("B3:0/0", "B", 3, 0, 0, null)]
|
||||
[InlineData("B3:2/7", "B", 3, 2, 7, null)]
|
||||
[InlineData("ST9:0", "ST", 9, 0, null, null)]
|
||||
[InlineData("L9:3", "L", 9, 3, null, null)]
|
||||
[InlineData("I:0/0", "I", null, 0, 0, null)]
|
||||
[InlineData("O:1/2", "O", null, 1, 2, null)]
|
||||
[InlineData("S:1", "S", null, 1, null, null)]
|
||||
[InlineData("T4:0.ACC", "T", 4, 0, null, "ACC")]
|
||||
[InlineData("T4:0.PRE", "T", 4, 0, null, "PRE")]
|
||||
[InlineData("C5:2.CU", "C", 5, 2, null, "CU")]
|
||||
[InlineData("R6:0.LEN", "R", 6, 0, null, "LEN")]
|
||||
[InlineData("N7:0/3", "N", 7, 0, 3, null)]
|
||||
public void TryParse_accepts_valid_pccc_addresses(string input, string letter, int? file, int word, int? bit, string? sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.FileNumber.ShouldBe(file);
|
||||
a.WordNumber.ShouldBe(word);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("N7")] // missing :word
|
||||
[InlineData(":0")] // missing file
|
||||
[InlineData("X7:0")] // unknown file letter
|
||||
[InlineData("N7:-1")] // negative word
|
||||
[InlineData("N7:abc")] // non-numeric word
|
||||
[InlineData("N7:0/-1")] // negative bit
|
||||
[InlineData("N7:0/32")] // bit out of range
|
||||
[InlineData("Nabc:0")] // non-numeric file number
|
||||
public void TryParse_rejects_invalid_forms(string? input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:0")]
|
||||
[InlineData("F8:5")]
|
||||
[InlineData("B3:0/0")]
|
||||
[InlineData("ST9:0")]
|
||||
[InlineData("T4:0.ACC")]
|
||||
[InlineData("I:0/0")]
|
||||
[InlineData("S:1")]
|
||||
public void ToLibplctagName_roundtrips(string input)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDriverTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriverType_is_AbLegacy()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("AbLegacy");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_devices_assigns_family_profiles()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/", AbLegacyPlcFamily.MicroLogix),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.7/1,0", AbLegacyPlcFamily.Plc5),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(3);
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Slc500);
|
||||
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.MicroLogix);
|
||||
drv.GetDeviceState("ab://10.0.0.7/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Plc5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_with_malformed_host_address_faults()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_expose_expected_defaults()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.LibplctagPlcAttribute.ShouldBe("slc500");
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsLongFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.DefaultCipPath.ShouldBe("1,0");
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.DefaultCipPath.ShouldBe("");
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.LibplctagPlcAttribute.ShouldBe("plc5");
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsLongFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.LibplctagPlcAttribute.ShouldBe("logixpccc");
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsLongFile.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "slc500")]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, "micrologix")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "plc5")]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, "logixpccc")]
|
||||
public void ForFamily_dispatches_correctly(AbLegacyPlcFamily family, string expectedAttribute)
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_pccc_types()
|
||||
{
|
||||
AbLegacyDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbLegacyDataType.Int.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Long.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataType.Float.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHostAndStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
|
||||
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
|
||||
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
|
||||
[InlineData("ab://plc-slc.factory/1,2", "plc-slc.factory", 44818, "1,2")]
|
||||
public void HostAddress_parses_valid(string input, string gateway, int port, string path)
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Gateway.ShouldBe(gateway);
|
||||
parsed.Port.ShouldBe(port);
|
||||
parsed.CipPath.ShouldBe(path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("http://10.0.0.5/1,0")]
|
||||
[InlineData("ab://10.0.0.5")]
|
||||
[InlineData("ab:///1,0")]
|
||||
[InlineData("ab://10.0.0.5:0/1,0")]
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_canonicalises()
|
||||
{
|
||||
new AbLegacyHostAddress("10.0.0.5", 44818, "1,0").ToString().ShouldBe("ab://10.0.0.5/1,0");
|
||||
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
|
||||
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x20, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x30, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x40, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x50, AbLegacyStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0xF0, AbLegacyStatusMapper.BadInternalError)]
|
||||
[InlineData((byte)0xFF, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
public void PcccStatus_maps_known_codes(byte sts, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, AbLegacyStatusMapper.Good)]
|
||||
[InlineData(1, AbLegacyStatusMapper.GoodMoreData)]
|
||||
[InlineData(-5, AbLegacyStatusMapper.BadTimeout)]
|
||||
[InlineData(-7, AbLegacyStatusMapper.BadCommunicationError)]
|
||||
[InlineData(-14, AbLegacyStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(-16, AbLegacyStatusMapper.BadNotWritable)]
|
||||
[InlineData(-17, AbLegacyStatusMapper.BadOutOfRange)]
|
||||
public void LibplctagStatus_maps_known_codes(int status, uint expected)
|
||||
{
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user